diff --git a/app/lint-baseline.xml b/app/lint-baseline.xml
index d2e5db31d..984a41bb8 100644
--- a/app/lint-baseline.xml
+++ b/app/lint-baseline.xml
@@ -740,7 +740,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
@@ -751,7 +751,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
@@ -762,7 +762,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
@@ -773,7 +773,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
@@ -1415,17 +1415,6 @@
column="1"/>
-
-
-
-
@@ -1807,7 +1796,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
@@ -1818,7 +1807,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
@@ -1829,7 +1818,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
@@ -1840,7 +1829,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
@@ -1851,7 +1840,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
@@ -1862,7 +1851,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~">
@@ -1873,7 +1862,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
@@ -1884,7 +1873,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~">
@@ -1895,7 +1884,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~">
@@ -1906,7 +1895,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~">
@@ -1917,7 +1906,7 @@
errorLine2=" ~~~~~~~~~~~~">
@@ -1928,7 +1917,7 @@
errorLine2=" ~~~~~~~~~~~~~~">
@@ -1939,7 +1928,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~">
@@ -1950,7 +1939,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~">
@@ -1961,7 +1950,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~">
@@ -1972,7 +1961,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~">
@@ -1983,7 +1972,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~">
@@ -1994,7 +1983,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~">
@@ -2005,7 +1994,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
@@ -2016,7 +2005,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~">
@@ -2027,7 +2016,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
@@ -2038,7 +2027,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
@@ -2049,7 +2038,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
@@ -2060,7 +2049,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~">
@@ -2071,7 +2060,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
@@ -2082,7 +2071,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~">
@@ -2093,7 +2082,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
@@ -2104,7 +2093,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~">
@@ -3703,6 +3692,39 @@
column="48"/>
+
+
+
+
+
+
+
+
+
+
+
+
@@ -4036,7 +4058,7 @@
errorLine2=" ~~~~~~~~">
@@ -4047,7 +4069,7 @@
errorLine2=" ~~~~~~~~">
@@ -4058,7 +4080,7 @@
errorLine2=" ~~~~~~~~">
@@ -4069,7 +4091,7 @@
errorLine2=" ~~~~~~~~">
@@ -4080,7 +4102,7 @@
errorLine2=" ~~~~~~~~">
@@ -4091,7 +4113,7 @@
errorLine2=" ~~~~~~~~">
@@ -4102,7 +4124,7 @@
errorLine2=" ~~~~~~~~">
@@ -4113,7 +4135,7 @@
errorLine2=" ~~~~~~~~">
diff --git a/app/src/main/java/app/pachli/adapter/FilterableStatusViewHolder.kt b/app/src/main/java/app/pachli/adapter/FilterableStatusViewHolder.kt
new file mode 100644
index 000000000..d5da21960
--- /dev/null
+++ b/app/src/main/java/app/pachli/adapter/FilterableStatusViewHolder.kt
@@ -0,0 +1,88 @@
+/*
+ * Copyright 2023 Pachli Association
+ *
+ * This file is a part of Pachli.
+ *
+ * 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.
+ *
+ * Pachli 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 Pachli; if not,
+ * see .
+ */
+
+package app.pachli.adapter
+
+import android.view.View
+import app.pachli.R
+import app.pachli.databinding.ItemStatusWrapperBinding
+import app.pachli.entity.Filter
+import app.pachli.interfaces.StatusActionListener
+import app.pachli.util.StatusDisplayOptions
+import app.pachli.viewdata.StatusViewData
+
+open class FilterableStatusViewHolder(
+ private val binding: ItemStatusWrapperBinding,
+) : StatusViewHolder(binding.statusContainer, binding.root) {
+
+ override fun setupWithStatus(
+ status: StatusViewData,
+ listener: StatusActionListener,
+ statusDisplayOptions: StatusDisplayOptions,
+ payloads: Any?,
+ ) {
+ super.setupWithStatus(status, listener, statusDisplayOptions, payloads)
+ setupFilterPlaceholder(status, listener)
+ }
+
+ private fun setupFilterPlaceholder(
+ status: StatusViewData,
+ listener: StatusActionListener,
+ ) {
+ if (status.filterAction !== Filter.Action.WARN) {
+ showFilteredPlaceholder(false)
+ return
+ }
+
+ // Shouldn't be necessary given the previous test against getFilterAction(),
+ // but guards against a possible NPE. See the TODO in StatusViewData.filterAction
+ // for more details.
+ val filterResults = status.actionable.filtered
+ if (filterResults.isNullOrEmpty()) {
+ showFilteredPlaceholder(false)
+ return
+ }
+ var matchedFilter: Filter? = null
+ for ((filter) in filterResults) {
+ if (filter.action === Filter.Action.WARN) {
+ matchedFilter = filter
+ break
+ }
+ }
+
+ // Guard against a possible NPE
+ if (matchedFilter == null) {
+ showFilteredPlaceholder(false)
+ return
+ }
+ showFilteredPlaceholder(true)
+ binding.statusFilteredPlaceholder.statusFilterLabel.text = context.getString(
+ R.string.status_filter_placeholder_label_format,
+ matchedFilter.title,
+ )
+ binding.statusFilteredPlaceholder.statusFilterShowAnyway.setOnClickListener {
+ listener.clearWarningAction(
+ bindingAdapterPosition,
+ )
+ }
+ }
+
+ private fun showFilteredPlaceholder(show: Boolean) {
+ binding.statusContainer.root.visibility = if (show) View.GONE else View.VISIBLE
+ binding.statusFilteredPlaceholder.root.visibility = if (show) View.VISIBLE else View.GONE
+ }
+}
diff --git a/app/src/main/java/app/pachli/adapter/StatusBaseViewHolder.java b/app/src/main/java/app/pachli/adapter/StatusBaseViewHolder.java
deleted file mode 100644
index 3adaac17b..000000000
--- a/app/src/main/java/app/pachli/adapter/StatusBaseViewHolder.java
+++ /dev/null
@@ -1,1037 +0,0 @@
-package app.pachli.adapter;
-
-import android.content.Context;
-import android.graphics.drawable.BitmapDrawable;
-import android.graphics.drawable.ColorDrawable;
-import android.graphics.drawable.Drawable;
-import android.text.Spanned;
-import android.text.TextUtils;
-import android.text.format.DateUtils;
-import android.view.Menu;
-import android.view.View;
-import android.view.ViewGroup;
-import android.widget.Button;
-import android.widget.ImageButton;
-import android.widget.ImageView;
-import android.widget.LinearLayout;
-import android.widget.TextView;
-import android.widget.Toast;
-
-import androidx.annotation.DrawableRes;
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
-import androidx.appcompat.widget.PopupMenu;
-import androidx.constraintlayout.widget.ConstraintLayout;
-import androidx.core.content.ContextCompat;
-import androidx.core.text.HtmlCompat;
-import androidx.recyclerview.widget.RecyclerView;
-
-import com.bumptech.glide.Glide;
-import com.google.android.material.button.MaterialButton;
-import com.google.android.material.color.MaterialColors;
-
-import java.text.NumberFormat;
-import java.util.Collections;
-import java.util.Date;
-import java.util.List;
-
-import app.pachli.R;
-import app.pachli.ViewMediaActivity;
-import app.pachli.entity.Attachment;
-import app.pachli.entity.Attachment.Focus;
-import app.pachli.entity.Attachment.MetaData;
-import app.pachli.entity.Card;
-import app.pachli.entity.Emoji;
-import app.pachli.entity.Filter;
-import app.pachli.entity.FilterResult;
-import app.pachli.entity.HashTag;
-import app.pachli.entity.Poll;
-import app.pachli.entity.PreviewCardKind;
-import app.pachli.entity.Status;
-import app.pachli.interfaces.StatusActionListener;
-import app.pachli.util.AbsoluteTimeFormatter;
-import app.pachli.util.AttachmentHelper;
-import app.pachli.util.CardViewMode;
-import app.pachli.util.CompositeWithOpaqueBackground;
-import app.pachli.util.CustomEmojiHelper;
-import app.pachli.util.ImageLoadingHelper;
-import app.pachli.util.LinkHelper;
-import app.pachli.util.NumberUtils;
-import app.pachli.util.StatusDisplayOptions;
-import app.pachli.util.TimestampUtils;
-import app.pachli.util.TouchDelegateHelper;
-import app.pachli.view.MediaPreviewImageView;
-import app.pachli.view.MediaPreviewLayout;
-import app.pachli.view.PollView;
-import app.pachli.view.PreviewCardView;
-import app.pachli.viewdata.PollViewData;
-import app.pachli.viewdata.StatusViewData;
-import at.connyduck.sparkbutton.SparkButton;
-import at.connyduck.sparkbutton.helpers.Utils;
-import kotlin.collections.CollectionsKt;
-
-public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
- public static class Key {
- public static final String KEY_CREATED = "created";
- }
-
- private final String TAG = "StatusBaseViewHolder";
-
- private final TextView displayName;
- private final TextView username;
- private final ImageButton replyButton;
- private final TextView replyCountLabel;
- private final SparkButton reblogButton;
- private final SparkButton favouriteButton;
- private final SparkButton bookmarkButton;
- private final ImageButton moreButton;
- private final ConstraintLayout mediaContainer;
- protected final MediaPreviewLayout mediaPreview;
- private final TextView sensitiveMediaWarning;
- private final View sensitiveMediaShow;
- @NonNull
- protected final TextView[] mediaLabels;
- @NonNull
- protected final CharSequence[] mediaDescriptions;
- private final MaterialButton contentWarningButton;
- private final ImageView avatarInset;
-
- public final ImageView avatar;
- public final TextView metaInfo;
- public final TextView content;
- public final TextView contentWarningDescription;
-
- @NonNull
- private final PollView pollView;
- private final PreviewCardView cardView;
- protected final LinearLayout filteredPlaceholder;
- protected final TextView filteredPlaceholderLabel;
- protected final Button filteredPlaceholderShowButton;
- protected final ConstraintLayout statusContainer;
-
- private final NumberFormat numberFormat = NumberFormat.getNumberInstance();
- private final AbsoluteTimeFormatter absoluteTimeFormatter = new AbsoluteTimeFormatter();
-
- protected final int avatarRadius48dp;
- private final int avatarRadius36dp;
- private final int avatarRadius24dp;
-
- @NonNull
- private final Drawable mediaPreviewUnloaded;
-
- protected StatusBaseViewHolder(@NonNull View itemView) {
- super(itemView);
- displayName = itemView.findViewById(R.id.status_display_name);
- username = itemView.findViewById(R.id.status_username);
- metaInfo = itemView.findViewById(R.id.status_meta_info);
- content = itemView.findViewById(R.id.status_content);
- avatar = itemView.findViewById(R.id.status_avatar);
- replyButton = itemView.findViewById(R.id.status_reply);
- replyCountLabel = itemView.findViewById(R.id.status_replies);
- reblogButton = itemView.findViewById(R.id.status_inset);
- favouriteButton = itemView.findViewById(R.id.status_favourite);
- bookmarkButton = itemView.findViewById(R.id.status_bookmark);
- moreButton = itemView.findViewById(R.id.status_more);
-
- mediaContainer = itemView.findViewById(R.id.status_media_preview_container);
- mediaContainer.setClipToOutline(true);
- mediaPreview = itemView.findViewById(R.id.status_media_preview);
-
- sensitiveMediaWarning = itemView.findViewById(R.id.status_sensitive_media_warning);
- sensitiveMediaShow = itemView.findViewById(R.id.status_sensitive_media_button);
- mediaLabels = new TextView[]{
- itemView.findViewById(R.id.status_media_label_0),
- itemView.findViewById(R.id.status_media_label_1),
- itemView.findViewById(R.id.status_media_label_2),
- itemView.findViewById(R.id.status_media_label_3)
- };
- mediaDescriptions = new CharSequence[mediaLabels.length];
- contentWarningDescription = itemView.findViewById(R.id.status_content_warning_description);
- contentWarningButton = itemView.findViewById(R.id.status_content_warning_button);
- avatarInset = itemView.findViewById(R.id.status_avatar_inset);
-
- pollView = itemView.findViewById(R.id.status_poll);
-
- cardView = itemView.findViewById(R.id.status_card_view);
-
- filteredPlaceholder = itemView.findViewById(R.id.status_filtered_placeholder);
- filteredPlaceholderLabel = itemView.findViewById(R.id.status_filter_label);
- filteredPlaceholderShowButton = itemView.findViewById(R.id.status_filter_show_anyway);
- statusContainer = itemView.findViewById(R.id.status_container);
-
- this.avatarRadius48dp = itemView.getContext().getResources().getDimensionPixelSize(R.dimen.avatar_radius_48dp);
- this.avatarRadius36dp = itemView.getContext().getResources().getDimensionPixelSize(R.dimen.avatar_radius_36dp);
- this.avatarRadius24dp = itemView.getContext().getResources().getDimensionPixelSize(R.dimen.avatar_radius_24dp);
-
- mediaPreviewUnloaded = new ColorDrawable(MaterialColors.getColor(itemView, android.R.attr.textColorLink));
-
- TouchDelegateHelper.expandTouchSizeToFillRow((ViewGroup) itemView, CollectionsKt.listOfNotNull(replyButton, reblogButton, favouriteButton, bookmarkButton, moreButton));
- }
-
- protected void setDisplayName(@NonNull String name, @Nullable List customEmojis, @NonNull StatusDisplayOptions statusDisplayOptions) {
- CharSequence emojifiedName = CustomEmojiHelper.emojify(
- name, customEmojis, displayName, statusDisplayOptions.animateEmojis()
- );
- displayName.setText(emojifiedName);
- }
-
- protected void setUsername(@NonNull String name) {
- Context context = username.getContext();
- String usernameText = context.getString(R.string.post_username_format, name);
- username.setText(usernameText);
- }
-
- public void toggleContentWarning() {
- contentWarningButton.performClick();
- }
-
- protected void setSpoilerAndContent(@NonNull StatusViewData status,
- @NonNull StatusDisplayOptions statusDisplayOptions,
- @NonNull final StatusActionListener listener) {
-
- Status actionable = status.getActionable();
- String spoilerText = status.getSpoilerText();
- List emojis = actionable.getEmojis();
-
- boolean sensitive = !TextUtils.isEmpty(spoilerText);
- boolean expanded = status.isExpanded();
-
- if (sensitive) {
- CharSequence emojiSpoiler = CustomEmojiHelper.emojify(
- spoilerText, emojis, contentWarningDescription, statusDisplayOptions.animateEmojis()
- );
- contentWarningDescription.setText(emojiSpoiler);
- contentWarningDescription.setVisibility(View.VISIBLE);
- contentWarningButton.setVisibility(View.VISIBLE);
- setContentWarningButtonText(expanded);
- contentWarningButton.setOnClickListener(view -> toggleExpandedState(true, !expanded, status, statusDisplayOptions, listener));
- this.setTextVisible(true, expanded, status, statusDisplayOptions, listener);
- } else {
- contentWarningDescription.setVisibility(View.GONE);
- contentWarningButton.setVisibility(View.GONE);
- this.setTextVisible(false, true, status, statusDisplayOptions, listener);
- }
- }
-
- private void setContentWarningButtonText(boolean expanded) {
- if (expanded) {
- contentWarningButton.setText(R.string.post_content_warning_show_less);
- } else {
- contentWarningButton.setText(R.string.post_content_warning_show_more);
- }
- }
-
- protected void toggleExpandedState(boolean sensitive,
- boolean expanded,
- @NonNull final StatusViewData status,
- @NonNull final StatusDisplayOptions statusDisplayOptions,
- @NonNull final StatusActionListener listener) {
-
- contentWarningDescription.invalidate();
- int adapterPosition = getBindingAdapterPosition();
- if (adapterPosition != RecyclerView.NO_POSITION) {
- listener.onExpandedChange(expanded, adapterPosition);
- }
- setContentWarningButtonText(expanded);
-
- this.setTextVisible(sensitive, expanded, status, statusDisplayOptions, listener);
-
- setupCard(status, expanded, statusDisplayOptions.cardViewMode(), statusDisplayOptions, listener);
- }
-
- private void setTextVisible(boolean sensitive,
- boolean expanded,
- @NonNull final StatusViewData status,
- @NonNull final StatusDisplayOptions statusDisplayOptions,
- @NonNull final StatusActionListener listener) {
-
- Status actionable = status.getActionable();
- Spanned content = status.getContent();
- List mentions = actionable.getMentions();
- List tags =actionable.getTags();
- List emojis = actionable.getEmojis();
- Poll poll = actionable.getPoll();
-
- if (expanded) {
- CharSequence emojifiedText = CustomEmojiHelper.emojify(content, emojis, this.content, statusDisplayOptions.animateEmojis());
- LinkHelper.setClickableText(this.content, emojifiedText, mentions, tags, listener);
- for (int i = 0; i < mediaLabels.length; ++i) {
- updateMediaLabel(i, sensitive, true);
- }
- if (poll != null) {
- PollView.OnClickListener pollListener = (List choices) -> {
- int position = getBindingAdapterPosition();
- if (position != RecyclerView.NO_POSITION) {
- if (choices == null) {
- listener.onViewThread(position);
- } else {
- listener.onVoteInPoll(position, choices);
- }
- }
- };
- pollView.bind(
- PollViewData.Companion.from(poll),
- emojis,
- statusDisplayOptions,
- numberFormat,
- absoluteTimeFormatter,
- pollListener
- );
- } else {
- pollView.hide();
- }
- } else {
- pollView.hide();
- LinkHelper.setClickableMentions(this.content, mentions, listener);
- }
- if (TextUtils.isEmpty(this.content.getText())) {
- this.content.setVisibility(View.GONE);
- } else {
- this.content.setVisibility(View.VISIBLE);
- }
- }
-
- private void setAvatar(String url,
- @Nullable String rebloggedUrl,
- boolean isBot,
- @NonNull StatusDisplayOptions statusDisplayOptions) {
-
- int avatarRadius;
- if (TextUtils.isEmpty(rebloggedUrl)) {
- avatar.setPaddingRelative(0, 0, 0, 0);
-
- if (statusDisplayOptions.showBotOverlay() && isBot) {
- avatarInset.setVisibility(View.VISIBLE);
- Glide.with(avatarInset)
- .load(R.drawable.bot_badge)
- .into(avatarInset);
- } else {
- avatarInset.setVisibility(View.GONE);
- }
-
- avatarRadius = avatarRadius48dp;
-
- } else {
- int padding = Utils.dpToPx(avatar.getContext(), 12);
- avatar.setPaddingRelative(0, 0, padding, padding);
-
- avatarInset.setVisibility(View.VISIBLE);
- avatarInset.setBackground(null);
- ImageLoadingHelper.loadAvatar(rebloggedUrl, avatarInset, avatarRadius24dp,
- statusDisplayOptions.animateAvatars(), null);
-
- avatarRadius = avatarRadius36dp;
- }
-
- ImageLoadingHelper.loadAvatar(url, avatar, avatarRadius,
- statusDisplayOptions.animateAvatars(),
- Collections.singletonList(new CompositeWithOpaqueBackground(avatar)));
- }
-
- protected void setMetaData(@NonNull StatusViewData statusViewData, @NonNull StatusDisplayOptions statusDisplayOptions, @NonNull StatusActionListener listener) {
-
- Status status = statusViewData.getActionable();
- Date createdAt = status.getCreatedAt();
- Date editedAt = status.getEditedAt();
-
- String timestampText;
- if (statusDisplayOptions.useAbsoluteTime()) {
- timestampText = absoluteTimeFormatter.format(createdAt, true);
- } else {
- if (createdAt == null) {
- timestampText = "?m";
- } else {
- long then = createdAt.getTime();
- long now = System.currentTimeMillis();
- timestampText = TimestampUtils.getRelativeTimeSpanString(metaInfo.getContext(), then, now);
- }
- }
-
- if (editedAt != null) {
- timestampText = metaInfo.getContext().getString(R.string.post_timestamp_with_edited_indicator, timestampText);
- }
- metaInfo.setText(timestampText);
- }
-
- private CharSequence getCreatedAtDescription(@Nullable Date createdAt,
- @NonNull StatusDisplayOptions statusDisplayOptions) {
- if (statusDisplayOptions.useAbsoluteTime()) {
- return absoluteTimeFormatter.format(createdAt, true);
- } else {
- /* This one is for screen-readers. Frequently, they would mispronounce timestamps like "17m"
- * as 17 meters instead of minutes. */
-
- if (createdAt == null) {
- return "? minutes";
- } else {
- long then = createdAt.getTime();
- long now = System.currentTimeMillis();
- return DateUtils.getRelativeTimeSpanString(then, now,
- DateUtils.SECOND_IN_MILLIS,
- DateUtils.FORMAT_ABBREV_RELATIVE);
- }
- }
- }
-
- protected void setIsReply(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) {
- // This label only exists in the non-detailed view (to match the web ui)
- if (replyCountLabel == null) return;
-
- if (fullStats) {
- replyCountLabel.setText(NumberUtils.formatNumber(repliesCount, 1000));
- return;
- }
-
- // Show "0", "1", or "1+" for replies otherwise, so the user knows if there is a thread
- // that they can click through to read.
- replyCountLabel.setText((repliesCount > 1 ? replyCountLabel.getContext().getString(R.string.status_count_one_plus) : Integer.toString(repliesCount)));
- }
-
- private void setReblogged(boolean reblogged) {
- reblogButton.setChecked(reblogged);
- }
-
- // This should only be called after setReblogged, in order to override the tint correctly.
- private void setRebloggingEnabled(boolean enabled, Status.Visibility visibility) {
- reblogButton.setEnabled(enabled && visibility != Status.Visibility.PRIVATE);
-
- if (enabled) {
- int inactiveId;
- int activeId;
- if (visibility == Status.Visibility.PRIVATE) {
- inactiveId = R.drawable.ic_reblog_private_24dp;
- activeId = R.drawable.ic_reblog_private_active_24dp;
- } else {
- inactiveId = R.drawable.ic_reblog_24dp;
- activeId = R.drawable.ic_reblog_active_24dp;
- }
- reblogButton.setInactiveImage(inactiveId);
- reblogButton.setActiveImage(activeId);
- } else {
- int disabledId;
- if (visibility == Status.Visibility.DIRECT) {
- disabledId = R.drawable.ic_reblog_direct_24dp;
- } else {
- disabledId = R.drawable.ic_reblog_private_24dp;
- }
- reblogButton.setInactiveImage(disabledId);
- reblogButton.setActiveImage(disabledId);
- }
- }
-
- protected void setFavourited(boolean favourited) {
- favouriteButton.setChecked(favourited);
- }
-
- protected void setBookmarked(boolean bookmarked) {
- bookmarkButton.setChecked(bookmarked);
- }
-
- @NonNull
- private BitmapDrawable decodeBlurHash(@NonNull String blurhash) {
- return ImageLoadingHelper.decodeBlurHash(this.avatar.getContext(), blurhash);
- }
-
- private void loadImage(@NonNull MediaPreviewImageView imageView,
- @Nullable String previewUrl,
- @Nullable MetaData meta,
- @Nullable String blurhash) {
-
- Drawable placeholder = blurhash != null ? decodeBlurHash(blurhash) : mediaPreviewUnloaded;
-
- if (TextUtils.isEmpty(previewUrl)) {
- imageView.removeFocalPoint();
-
- Glide.with(imageView)
- .load(placeholder)
- .centerInside()
- .into(imageView);
- } else {
- Focus focus = meta != null ? meta.getFocus() : null;
-
- if (focus != null) { // If there is a focal point for this attachment:
- imageView.setFocalPoint(focus);
-
- Glide.with(imageView.getContext())
- .load(previewUrl)
- .placeholder(placeholder)
- .centerInside()
- .addListener(imageView)
- .into(imageView);
- } else {
- imageView.removeFocalPoint();
-
- Glide.with(imageView)
- .load(previewUrl)
- .placeholder(placeholder)
- .centerInside()
- .into(imageView);
- }
- }
- }
-
- protected void setMediaPreviews(
- @NonNull final List attachments,
- boolean sensitive,
- @NonNull final StatusActionListener listener,
- boolean showingContent,
- boolean useBlurhash
- ) {
-
- mediaPreview.setVisibility(View.VISIBLE);
- mediaPreview.setAspectRatios(AttachmentHelper.aspectRatios(attachments));
-
- mediaPreview.forEachIndexed((i, imageView, descriptionIndicator) -> {
- Attachment attachment = attachments.get(i);
- String previewUrl = attachment.getPreviewUrl();
- String description = attachment.getDescription();
- boolean hasDescription = !TextUtils.isEmpty(description);
-
- if (hasDescription) {
- imageView.setContentDescription(description);
- } else {
- imageView.setContentDescription(imageView.getContext().getString(R.string.action_view_media));
- }
-
- loadImage(
- imageView,
- showingContent ? previewUrl : null,
- attachment.getMeta(),
- useBlurhash ? attachment.getBlurhash() : null
- );
-
- final Attachment.Type type = attachment.getType();
- if (showingContent && (type == Attachment.Type.VIDEO || type == Attachment.Type.GIFV)) {
- imageView.setForeground(ContextCompat.getDrawable(itemView.getContext(), R.drawable.play_indicator_overlay));
- } else {
- imageView.setForeground(null);
- }
-
- setAttachmentClickListener(imageView, listener, i, attachment, true);
-
- if (sensitive) {
- sensitiveMediaWarning.setText(R.string.post_sensitive_media_title);
- } else {
- sensitiveMediaWarning.setText(R.string.post_media_hidden_title);
- }
-
- sensitiveMediaWarning.setVisibility(showingContent ? View.GONE : View.VISIBLE);
- sensitiveMediaShow.setVisibility(showingContent ? View.VISIBLE : View.GONE);
-
- descriptionIndicator.setVisibility(hasDescription && showingContent ? View.VISIBLE : View.GONE);
-
- sensitiveMediaShow.setOnClickListener(v -> {
- if (getBindingAdapterPosition() != RecyclerView.NO_POSITION) {
- listener.onContentHiddenChange(false, getBindingAdapterPosition());
- }
- v.setVisibility(View.GONE);
- sensitiveMediaWarning.setVisibility(View.VISIBLE);
- descriptionIndicator.setVisibility(View.GONE);
- });
- sensitiveMediaWarning.setOnClickListener(v -> {
- if (getBindingAdapterPosition() != RecyclerView.NO_POSITION) {
- listener.onContentHiddenChange(true, getBindingAdapterPosition());
- }
- v.setVisibility(View.GONE);
- sensitiveMediaShow.setVisibility(View.VISIBLE);
- descriptionIndicator.setVisibility(hasDescription ? View.VISIBLE : View.GONE);
- });
-
- return null;
- });
- }
-
- @DrawableRes
- private static int getLabelIcon(@NonNull Attachment.Type type) {
- return switch (type) {
- case IMAGE -> R.drawable.ic_photo_24dp;
- case GIFV, VIDEO -> R.drawable.ic_videocam_24dp;
- case AUDIO -> R.drawable.ic_music_box_24dp;
- default -> R.drawable.ic_attach_file_24dp;
- };
- }
-
- private void updateMediaLabel(int index, boolean sensitive, boolean showingContent) {
- Context context = itemView.getContext();
- CharSequence label = (sensitive && !showingContent) ?
- context.getString(R.string.post_sensitive_media_title) :
- mediaDescriptions[index];
- mediaLabels[index].setText(label);
- }
-
- protected void setMediaLabel(@NonNull List attachments, boolean sensitive,
- @NonNull final StatusActionListener listener, boolean showingContent) {
- Context context = itemView.getContext();
- for (int i = 0; i < mediaLabels.length; i++) {
- TextView mediaLabel = mediaLabels[i];
- if (i < attachments.size()) {
- Attachment attachment = attachments.get(i);
- mediaLabel.setVisibility(View.VISIBLE);
- mediaDescriptions[i] = AttachmentHelper.getFormattedDescription(attachment, context);
- updateMediaLabel(i, sensitive, showingContent);
-
- // Set the icon next to the label.
- int drawableId = getLabelIcon(attachments.get(0).getType());
- mediaLabel.setCompoundDrawablesWithIntrinsicBounds(drawableId, 0, 0, 0);
-
- setAttachmentClickListener(mediaLabel, listener, i, attachment, false);
- } else {
- mediaLabel.setVisibility(View.GONE);
- }
- }
- }
-
- private void setAttachmentClickListener(@NonNull View view, @NonNull StatusActionListener listener,
- int index, @NonNull Attachment attachment, boolean animateTransition) {
- view.setOnClickListener(v -> {
- int position = getBindingAdapterPosition();
- if (position != RecyclerView.NO_POSITION) {
- if (sensitiveMediaWarning.getVisibility() == View.VISIBLE) {
- listener.onContentHiddenChange(true, getBindingAdapterPosition());
- } else {
- listener.onViewMedia(position, index, animateTransition ? v : null);
- }
- }
- });
- view.setOnLongClickListener(v -> {
- CharSequence description = AttachmentHelper.getFormattedDescription(attachment, view.getContext());
- Toast.makeText(view.getContext(), description, Toast.LENGTH_LONG).show();
- return true;
- });
- }
-
- protected void hideSensitiveMediaWarning() {
- sensitiveMediaWarning.setVisibility(View.GONE);
- sensitiveMediaShow.setVisibility(View.GONE);
- }
-
- protected void setupButtons(@NonNull final StatusActionListener listener,
- @NonNull final String accountId,
- @Nullable final String statusContent,
- @NonNull StatusDisplayOptions statusDisplayOptions) {
- View.OnClickListener profileButtonClickListener = button -> listener.onViewAccount(accountId);
-
- avatar.setOnClickListener(profileButtonClickListener);
- displayName.setOnClickListener(profileButtonClickListener);
-
- replyButton.setOnClickListener(v -> {
- int position = getBindingAdapterPosition();
- if (position != RecyclerView.NO_POSITION) {
- listener.onReply(position);
- }
- });
-
-
- if (reblogButton != null) {
- reblogButton.setEventListener((button, buttonState) -> {
- // return true to play animation
- int position = getBindingAdapterPosition();
- if (position != RecyclerView.NO_POSITION) {
- if (statusDisplayOptions.confirmReblogs()) {
- showConfirmReblog(listener, buttonState, position);
- return false;
- } else {
- listener.onReblog(!buttonState, position);
- return true;
- }
- } else {
- return false;
- }
- });
- }
-
-
- favouriteButton.setEventListener((button, buttonState) -> {
- // return true to play animation
- int position = getBindingAdapterPosition();
- if (position != RecyclerView.NO_POSITION) {
- if (statusDisplayOptions.confirmFavourites()) {
- showConfirmFavourite(listener, buttonState, position);
- return false;
- } else {
- listener.onFavourite(!buttonState, position);
- return true;
- }
- } else {
- return true;
- }
- });
-
- bookmarkButton.setEventListener((button, buttonState) -> {
- int position = getBindingAdapterPosition();
- if (position != RecyclerView.NO_POSITION) {
- listener.onBookmark(!buttonState, position);
- }
- return true;
- });
-
- moreButton.setOnClickListener(v -> {
- int position = getBindingAdapterPosition();
- if (position != RecyclerView.NO_POSITION) {
- listener.onMore(v, position);
- }
- });
- /* Even though the content TextView is a child of the container, it won't respond to clicks
- * if it contains URLSpans without also setting its listener. The surrounding spans will
- * just eat the clicks instead of deferring to the parent listener, but WILL respond to a
- * listener directly on the TextView, for whatever reason. */
- View.OnClickListener viewThreadListener = v -> {
- int position = getBindingAdapterPosition();
- if (position != RecyclerView.NO_POSITION) {
- listener.onViewThread(position);
- }
- };
- content.setOnClickListener(viewThreadListener);
- itemView.setOnClickListener(viewThreadListener);
- }
-
- private void showConfirmReblog(@NonNull StatusActionListener listener,
- boolean buttonState,
- int position) {
- PopupMenu popup = new PopupMenu(itemView.getContext(), reblogButton);
- popup.inflate(R.menu.status_reblog);
- Menu menu = popup.getMenu();
- if (buttonState) {
- menu.findItem(R.id.menu_action_reblog).setVisible(false);
- } else {
- menu.findItem(R.id.menu_action_unreblog).setVisible(false);
- }
- popup.setOnMenuItemClickListener(item -> {
- listener.onReblog(!buttonState, position);
- if(!buttonState) {
- reblogButton.playAnimation();
- }
- return true;
- });
- popup.show();
- }
-
- private void showConfirmFavourite(@NonNull StatusActionListener listener,
- boolean buttonState,
- int position) {
- PopupMenu popup = new PopupMenu(itemView.getContext(), favouriteButton);
- popup.inflate(R.menu.status_favourite);
- Menu menu = popup.getMenu();
- if (buttonState) {
- menu.findItem(R.id.menu_action_favourite).setVisible(false);
- } else {
- menu.findItem(R.id.menu_action_unfavourite).setVisible(false);
- }
- popup.setOnMenuItemClickListener(item -> {
- listener.onFavourite(!buttonState, position);
- if(!buttonState) {
- favouriteButton.playAnimation();
- }
- return true;
- });
- popup.show();
- }
-
- public void setupWithStatus(@NonNull StatusViewData status, @NonNull final StatusActionListener listener,
- @NonNull StatusDisplayOptions statusDisplayOptions) {
- this.setupWithStatus(status, listener, statusDisplayOptions, null);
- }
-
- public void setupWithStatus(@NonNull StatusViewData status,
- @NonNull final StatusActionListener listener,
- @NonNull StatusDisplayOptions statusDisplayOptions,
- @Nullable Object payloads) {
- if (payloads == null) {
- Status actionable = status.getActionable();
- setDisplayName(actionable.getAccount().getName(), actionable.getAccount().getEmojis(), statusDisplayOptions);
- setUsername(status.getUsername());
- setMetaData(status, statusDisplayOptions, listener);
- setIsReply(actionable.getInReplyToId() != null);
- setReplyCount(actionable.getRepliesCount(), statusDisplayOptions.showStatsInline());
- setAvatar(actionable.getAccount().getAvatar(), status.getRebloggedAvatar(),
- actionable.getAccount().getBot(), statusDisplayOptions);
- setReblogged(actionable.getReblogged());
- setFavourited(actionable.getFavourited());
- setBookmarked(actionable.getBookmarked());
- List attachments = actionable.getAttachments();
- boolean sensitive = actionable.getSensitive();
- if (statusDisplayOptions.mediaPreviewEnabled() && hasPreviewableAttachment(attachments)) {
- setMediaPreviews(attachments, sensitive, listener, status.isShowingContent(), statusDisplayOptions.useBlurhash());
-
- if (attachments.size() == 0) {
- hideSensitiveMediaWarning();
- }
- // Hide the unused label.
- for (TextView mediaLabel : mediaLabels) {
- mediaLabel.setVisibility(View.GONE);
- }
- } else {
- setMediaLabel(attachments, sensitive, listener, status.isShowingContent());
- // Hide all unused views.
- mediaPreview.setVisibility(View.GONE);
- hideSensitiveMediaWarning();
- }
-
- setupCard(status, status.isExpanded(), statusDisplayOptions.cardViewMode(), statusDisplayOptions, listener);
-
- setupButtons(listener, actionable.getAccount().getId(), status.getContent().toString(),
- statusDisplayOptions);
- setRebloggingEnabled(actionable.rebloggingAllowed(), actionable.getVisibility());
-
- setSpoilerAndContent(status, statusDisplayOptions, listener);
-
- setupFilterPlaceholder(status, listener, statusDisplayOptions);
-
- setDescriptionForStatus(status, statusDisplayOptions);
-
- // Workaround for RecyclerView 1.0.0 / androidx.core 1.0.0
- // RecyclerView tries to set AccessibilityDelegateCompat to null
- // but ViewCompat code replaces is with the default one. RecyclerView never
- // fetches another one from its delegate because it checks that it's set so we remove it
- // and let RecyclerView ask for a new delegate.
- itemView.setAccessibilityDelegate(null);
- } else {
- if (payloads instanceof List)
- for (Object item : (List>) payloads) {
- if (Key.KEY_CREATED.equals(item)) {
- setMetaData(status, statusDisplayOptions, listener);
- }
- }
-
- }
- }
-
- private void setupFilterPlaceholder(@NonNull StatusViewData status, @NonNull StatusActionListener listener, StatusDisplayOptions displayOptions) {
- if (status.getFilterAction() != Filter.Action.WARN) {
- showFilteredPlaceholder(false);
- return;
- }
-
- // Shouldn't be necessary given the previous test against getFilterAction(),
- // but guards against a possible NPE. See the TODO in StatusViewData.filterAction
- // for more details.
- List filterResults = status.getActionable().getFiltered();
- if (filterResults == null || filterResults.isEmpty()) {
- showFilteredPlaceholder(false);
- return;
- }
-
- Filter matchedFilter = null;
-
- for (FilterResult result : filterResults) {
- Filter filter = result.getFilter();
- if (filter.getAction() == Filter.Action.WARN) {
- matchedFilter = filter;
- break;
- }
- }
-
- // Guard against a possible NPE
- if (matchedFilter == null) {
- showFilteredPlaceholder(false);
- return;
- }
-
- showFilteredPlaceholder(true);
-
- filteredPlaceholderLabel.setText(itemView.getContext().getString(R.string.status_filter_placeholder_label_format, matchedFilter.getTitle()));
- filteredPlaceholderShowButton.setOnClickListener(view -> listener.clearWarningAction(getBindingAdapterPosition()));
- }
-
- protected static boolean hasPreviewableAttachment(@NonNull List attachments) {
- for (Attachment attachment : attachments) {
- if (attachment.getType() == Attachment.Type.AUDIO || attachment.getType() == Attachment.Type.UNKNOWN) {
- return false;
- }
- }
- return true;
- }
-
- private void setDescriptionForStatus(@NonNull StatusViewData status,
- @NonNull StatusDisplayOptions statusDisplayOptions) {
- Context context = itemView.getContext();
- Status actionable = status.getActionable();
-
- Poll poll = actionable.getPoll();
- CharSequence pollDescription = "";
- if (poll != null) {
- pollDescription = pollView.getPollDescription(
- PollViewData.Companion.from(poll),
- statusDisplayOptions,
- numberFormat,
- absoluteTimeFormatter
- );
- }
-
- String description = context.getString(R.string.description_status,
- actionable.getAccount().getDisplayName(),
- getContentWarningDescription(context, status),
- (TextUtils.isEmpty(status.getSpoilerText()) || !actionable.getSensitive() || status.isExpanded() ? status.getContent() : ""),
- getCreatedAtDescription(actionable.getCreatedAt(), statusDisplayOptions),
- actionable.getEditedAt() != null ? context.getString(R.string.description_post_edited) : "",
- getReblogDescription(context, status),
- status.getUsername(),
- actionable.getReblogged() ? context.getString(R.string.description_post_reblogged) : "",
- actionable.getFavourited() ? context.getString(R.string.description_post_favourited) : "",
- actionable.getBookmarked() ? context.getString(R.string.description_post_bookmarked) : "",
- getMediaDescription(context, status),
- getVisibilityDescription(context, actionable.getVisibility()),
- getFavsText(context, actionable.getFavouritesCount()),
- getReblogsText(context, actionable.getReblogsCount()),
- pollDescription
- );
- itemView.setContentDescription(description);
- }
-
- @NonNull
- private static CharSequence getReblogDescription(@NonNull Context context,
- @NonNull StatusViewData status) {
- Status reblog = status.getRebloggingStatus();
- if (reblog != null) {
- return context
- .getString(R.string.post_boosted_format, reblog.getAccount().getUsername());
- } else {
- return "";
- }
- }
-
- @NonNull
- private static CharSequence getMediaDescription(@NonNull Context context,
- @NonNull StatusViewData status) {
- if (status.getActionable().getAttachments().isEmpty()) {
- return "";
- }
- StringBuilder mediaDescriptions = CollectionsKt.fold(
- status.getActionable().getAttachments(),
- new StringBuilder(),
- (builder, a) -> {
- if (a.getDescription() == null) {
- String placeholder =
- context.getString(R.string.description_post_media_no_description_placeholder);
- return builder.append(placeholder);
- } else {
- builder.append("; ");
- return builder.append(a.getDescription());
- }
- });
- return context.getString(R.string.description_post_media, mediaDescriptions);
- }
-
- @NonNull
- private static CharSequence getContentWarningDescription(@NonNull Context context,
- @NonNull StatusViewData status) {
- if (!TextUtils.isEmpty(status.getSpoilerText())) {
- return context.getString(R.string.description_post_cw, status.getSpoilerText());
- } else {
- return "";
- }
- }
-
- @NonNull
- protected static CharSequence getVisibilityDescription(@NonNull Context context, @Nullable Status.Visibility visibility) {
-
- if (visibility == null) {
- return "";
- }
-
- int resource;
- switch (visibility) {
- case PUBLIC -> resource = R.string.description_visibility_public;
- case UNLISTED -> resource = R.string.description_visibility_unlisted;
- case PRIVATE -> resource = R.string.description_visibility_private;
- case DIRECT -> resource = R.string.description_visibility_direct;
- default -> {
- return "";
- }
- }
- return context.getString(resource);
- }
-
- @NonNull
- protected CharSequence getFavsText(@NonNull Context context, int count) {
- if (count > 0) {
- String countString = numberFormat.format(count);
- return HtmlCompat.fromHtml(context.getResources().getQuantityString(R.plurals.favs, count, countString), HtmlCompat.FROM_HTML_MODE_LEGACY);
- } else {
- return "";
- }
- }
-
- @NonNull
- protected CharSequence getReblogsText(@NonNull Context context, int count) {
- if (count > 0) {
- String countString = numberFormat.format(count);
- return HtmlCompat.fromHtml(context.getResources().getQuantityString(R.plurals.reblogs, count, countString), HtmlCompat.FROM_HTML_MODE_LEGACY);
- } else {
- return "";
- }
- }
-
- protected void setupCard(
- @NonNull final StatusViewData status,
- boolean expanded,
- @NonNull final CardViewMode cardViewMode,
- @NonNull final StatusDisplayOptions statusDisplayOptions,
- @NonNull final StatusActionListener listener
- ) {
- if (cardView == null) return;
-
- final Status actionable = status.getActionable();
- final Card card = actionable.getCard();
-
- if (cardViewMode != CardViewMode.NONE &&
- actionable.getAttachments().size() == 0 &&
- actionable.getPoll() == null &&
- card != null &&
- !TextUtils.isEmpty(card.getUrl()) &&
- (!actionable.getSensitive() || expanded) &&
- (!status.isCollapsible() || !status.isCollapsed())) {
-
- cardView.setVisibility(View.VISIBLE);
- PreviewCardView.OnClickListener cardListener = (PreviewCardView.Target target) -> {
- if (card.getKind().equals(PreviewCardKind.PHOTO) && !TextUtils.isEmpty(card.getEmbedUrl())) {
- cardView.getContext().startActivity(ViewMediaActivity.newSingleImageIntent(cardView.getContext(), card.getEmbedUrl()));
- } else {
- listener.onViewUrl(card.getUrl());
- }
- };
-
- cardView.bind(card, actionable.getSensitive(), statusDisplayOptions, cardListener);
- } else {
- cardView.setVisibility(View.GONE);
- }
- }
-
- public void showStatusContent(boolean show) {
- int visibility = show ? View.VISIBLE : View.GONE;
- avatar.setVisibility(visibility);
- avatarInset.setVisibility(visibility);
- displayName.setVisibility(visibility);
- username.setVisibility(visibility);
- metaInfo.setVisibility(visibility);
- contentWarningDescription.setVisibility(visibility);
- contentWarningButton.setVisibility(visibility);
- content.setVisibility(visibility);
- cardView.setVisibility(visibility);
- mediaContainer.setVisibility(visibility);
- pollView.setVisibility(visibility);
- replyButton.setVisibility(visibility);
- reblogButton.setVisibility(visibility);
- favouriteButton.setVisibility(visibility);
- bookmarkButton.setVisibility(visibility);
- moreButton.setVisibility(visibility);
- }
-
- public void showFilteredPlaceholder(boolean show) {
- if (statusContainer != null) {
- statusContainer.setVisibility(show ? View.GONE : View.VISIBLE);
- }
- if (filteredPlaceholder != null) {
- filteredPlaceholder.setVisibility(show ? View.VISIBLE : View.GONE);
- }
- }
-}
diff --git a/app/src/main/java/app/pachli/adapter/StatusBaseViewHolder.kt b/app/src/main/java/app/pachli/adapter/StatusBaseViewHolder.kt
new file mode 100644
index 000000000..c9f048878
--- /dev/null
+++ b/app/src/main/java/app/pachli/adapter/StatusBaseViewHolder.kt
@@ -0,0 +1,915 @@
+package app.pachli.adapter
+
+import android.content.Context
+import android.graphics.drawable.BitmapDrawable
+import android.graphics.drawable.ColorDrawable
+import android.graphics.drawable.Drawable
+import android.text.TextUtils
+import android.text.format.DateUtils
+import android.view.View
+import android.view.ViewGroup
+import android.widget.Button
+import android.widget.ImageButton
+import android.widget.ImageView
+import android.widget.LinearLayout
+import android.widget.TextView
+import android.widget.Toast
+import androidx.appcompat.widget.PopupMenu
+import androidx.constraintlayout.widget.ConstraintLayout
+import androidx.core.content.ContextCompat
+import androidx.core.text.HtmlCompat
+import androidx.recyclerview.widget.RecyclerView
+import app.pachli.R
+import app.pachli.ViewMediaActivity.Companion.newSingleImageIntent
+import app.pachli.entity.Attachment
+import app.pachli.entity.Emoji
+import app.pachli.entity.PreviewCardKind
+import app.pachli.entity.Status
+import app.pachli.entity.description
+import app.pachli.interfaces.StatusActionListener
+import app.pachli.util.AbsoluteTimeFormatter
+import app.pachli.util.CardViewMode
+import app.pachli.util.CompositeWithOpaqueBackground
+import app.pachli.util.StatusDisplayOptions
+import app.pachli.util.aspectRatios
+import app.pachli.util.decodeBlurHash
+import app.pachli.util.emojify
+import app.pachli.util.expandTouchSizeToFillRow
+import app.pachli.util.formatNumber
+import app.pachli.util.getFormattedDescription
+import app.pachli.util.getRelativeTimeSpanString
+import app.pachli.util.hide
+import app.pachli.util.loadAvatar
+import app.pachli.util.setClickableMentions
+import app.pachli.util.setClickableText
+import app.pachli.view.MediaPreviewImageView
+import app.pachli.view.MediaPreviewLayout
+import app.pachli.view.PollView
+import app.pachli.view.PreviewCardView
+import app.pachli.viewdata.PollViewData.Companion.from
+import app.pachli.viewdata.StatusViewData
+import at.connyduck.sparkbutton.SparkButton
+import at.connyduck.sparkbutton.helpers.Utils
+import com.bumptech.glide.Glide
+import com.google.android.material.button.MaterialButton
+import com.google.android.material.color.MaterialColors
+import java.text.NumberFormat
+import java.util.Date
+
+abstract class StatusBaseViewHolder protected constructor(itemView: View) :
+ RecyclerView.ViewHolder(itemView) {
+ object Key {
+ const val KEY_CREATED = "created"
+ }
+
+ protected val context: Context
+ private val displayName: TextView
+ private val username: TextView
+ private val replyButton: ImageButton
+ private val replyCountLabel: TextView?
+ private val reblogButton: SparkButton?
+ private val favouriteButton: SparkButton
+ private val bookmarkButton: SparkButton
+ private val moreButton: ImageButton
+ private val mediaContainer: ConstraintLayout
+ protected val mediaPreview: MediaPreviewLayout
+ private val sensitiveMediaWarning: TextView
+ private val sensitiveMediaShow: View
+ protected val mediaLabels: Array
+ private val mediaDescriptions: Array
+ private val contentWarningButton: MaterialButton
+ private val avatarInset: ImageView
+ val avatar: ImageView
+ val metaInfo: TextView
+ val content: TextView
+ private val contentWarningDescription: TextView
+ private val pollView: PollView
+ private val cardView: PreviewCardView?
+ private val filteredPlaceholder: LinearLayout?
+ private val filteredPlaceholderLabel: TextView?
+ private val filteredPlaceholderShowButton: Button?
+ private val statusContainer: ConstraintLayout?
+ private val numberFormat = NumberFormat.getNumberInstance()
+ private val absoluteTimeFormatter = AbsoluteTimeFormatter()
+ protected val avatarRadius48dp: Int
+ private val avatarRadius36dp: Int
+ private val avatarRadius24dp: Int
+ private val mediaPreviewUnloaded: Drawable
+
+ init {
+ context = itemView.context
+ displayName = itemView.findViewById(R.id.status_display_name)
+ username = itemView.findViewById(R.id.status_username)
+ metaInfo = itemView.findViewById(R.id.status_meta_info)
+ content = itemView.findViewById(R.id.status_content)
+ avatar = itemView.findViewById(R.id.status_avatar)
+ replyButton = itemView.findViewById(R.id.status_reply)
+ replyCountLabel = itemView.findViewById(R.id.status_replies)
+ reblogButton = itemView.findViewById(R.id.status_inset)
+ favouriteButton = itemView.findViewById(R.id.status_favourite)
+ bookmarkButton = itemView.findViewById(R.id.status_bookmark)
+ moreButton = itemView.findViewById(R.id.status_more)
+ mediaContainer = itemView.findViewById(R.id.status_media_preview_container)
+ mediaContainer.clipToOutline = true
+ mediaPreview = itemView.findViewById(R.id.status_media_preview)
+ sensitiveMediaWarning = itemView.findViewById(R.id.status_sensitive_media_warning)
+ sensitiveMediaShow = itemView.findViewById(R.id.status_sensitive_media_button)
+ mediaLabels = arrayOf(
+ itemView.findViewById(R.id.status_media_label_0),
+ itemView.findViewById(R.id.status_media_label_1),
+ itemView.findViewById(R.id.status_media_label_2),
+ itemView.findViewById(R.id.status_media_label_3),
+ )
+ mediaDescriptions = arrayOfNulls(mediaLabels.size)
+ contentWarningDescription = itemView.findViewById(R.id.status_content_warning_description)
+ contentWarningButton = itemView.findViewById(R.id.status_content_warning_button)
+ avatarInset = itemView.findViewById(R.id.status_avatar_inset)
+ pollView = itemView.findViewById(R.id.status_poll)
+ cardView = itemView.findViewById(R.id.status_card_view)
+ filteredPlaceholder = itemView.findViewById(R.id.status_filtered_placeholder)
+ filteredPlaceholderLabel = itemView.findViewById(R.id.status_filter_label)
+ filteredPlaceholderShowButton = itemView.findViewById(R.id.status_filter_show_anyway)
+ statusContainer = itemView.findViewById(R.id.status_container)
+ avatarRadius48dp = context.resources.getDimensionPixelSize(R.dimen.avatar_radius_48dp)
+ avatarRadius36dp = context.resources.getDimensionPixelSize(R.dimen.avatar_radius_36dp)
+ avatarRadius24dp = context.resources.getDimensionPixelSize(R.dimen.avatar_radius_24dp)
+ mediaPreviewUnloaded =
+ ColorDrawable(MaterialColors.getColor(itemView, android.R.attr.textColorLink))
+ (itemView as ViewGroup).expandTouchSizeToFillRow(
+ listOfNotNull(
+ replyButton,
+ reblogButton,
+ favouriteButton,
+ bookmarkButton,
+ moreButton,
+ ),
+ )
+ }
+
+ protected fun setDisplayName(
+ name: String,
+ customEmojis: List?,
+ statusDisplayOptions: StatusDisplayOptions,
+ ) {
+ displayName.text = name.emojify(customEmojis, displayName, statusDisplayOptions.animateEmojis)
+ }
+
+ protected fun setUsername(name: String) {
+ username.text = context.getString(R.string.post_username_format, name)
+ }
+
+ fun toggleContentWarning() {
+ contentWarningButton.performClick()
+ }
+
+ protected fun setSpoilerAndContent(
+ status: StatusViewData,
+ statusDisplayOptions: StatusDisplayOptions,
+ listener: StatusActionListener,
+ ) {
+ val (_, _, _, _, _, _, _, _, _, emojis) = status.actionable
+ val spoilerText = status.spoilerText
+ val sensitive = !TextUtils.isEmpty(spoilerText)
+ val expanded = status.isExpanded
+ if (sensitive) {
+ val emojiSpoiler = spoilerText.emojify(
+ emojis,
+ contentWarningDescription,
+ statusDisplayOptions.animateEmojis,
+ )
+ contentWarningDescription.text = emojiSpoiler
+ contentWarningDescription.visibility = View.VISIBLE
+ contentWarningButton.visibility = View.VISIBLE
+ setContentWarningButtonText(expanded)
+ contentWarningButton.setOnClickListener {
+ toggleExpandedState(
+ true,
+ !expanded,
+ status,
+ statusDisplayOptions,
+ listener,
+ )
+ }
+ setTextVisible(true, expanded, status, statusDisplayOptions, listener)
+ return
+ }
+
+ contentWarningDescription.visibility = View.GONE
+ contentWarningButton.visibility = View.GONE
+ setTextVisible(
+ sensitive = false,
+ expanded = true,
+ status = status,
+ statusDisplayOptions = statusDisplayOptions,
+ listener = listener,
+ )
+ }
+
+ private fun setContentWarningButtonText(expanded: Boolean) {
+ if (expanded) {
+ contentWarningButton.setText(R.string.post_content_warning_show_less)
+ } else {
+ contentWarningButton.setText(R.string.post_content_warning_show_more)
+ }
+ }
+
+ protected open fun toggleExpandedState(
+ sensitive: Boolean,
+ expanded: Boolean,
+ status: StatusViewData,
+ statusDisplayOptions: StatusDisplayOptions,
+ listener: StatusActionListener,
+ ) {
+ contentWarningDescription.invalidate()
+ val adapterPosition = bindingAdapterPosition
+ if (adapterPosition != RecyclerView.NO_POSITION) {
+ listener.onExpandedChange(expanded, adapterPosition)
+ }
+ setContentWarningButtonText(expanded)
+ setTextVisible(sensitive, expanded, status, statusDisplayOptions, listener)
+ setupCard(
+ status,
+ expanded,
+ statusDisplayOptions.cardViewMode,
+ statusDisplayOptions,
+ listener,
+ )
+ }
+
+ private fun setTextVisible(
+ sensitive: Boolean,
+ expanded: Boolean,
+ status: StatusViewData,
+ statusDisplayOptions: StatusDisplayOptions,
+ listener: StatusActionListener,
+ ) {
+ val (_, _, _, _, _, _, _, _, _, emojis, _, _, _, _, _, _, _, _, _, _, mentions, tags, _, _, _, poll) = status.actionable
+ val content = status.content
+ if (expanded) {
+ val emojifiedText =
+ content.emojify(emojis, this.content, statusDisplayOptions.animateEmojis)
+ setClickableText(this.content, emojifiedText, mentions, tags, listener)
+ for (i in mediaLabels.indices) {
+ updateMediaLabel(i, sensitive, true)
+ }
+
+ poll?.let {
+ pollView.bind(
+ from(it),
+ emojis,
+ statusDisplayOptions,
+ numberFormat,
+ absoluteTimeFormatter,
+ ) { choices ->
+ val position = bindingAdapterPosition
+ if (position != RecyclerView.NO_POSITION) {
+ choices?.let { listener.onVoteInPoll(position, it) }
+ ?: listener.onViewThread(position)
+ }
+ }
+ } ?: pollView.hide()
+ } else {
+ pollView.hide()
+ setClickableMentions(this.content, mentions, listener)
+ }
+ if (TextUtils.isEmpty(this.content.text)) {
+ this.content.visibility = View.GONE
+ } else {
+ this.content.visibility = View.VISIBLE
+ }
+ }
+
+ private fun setAvatar(
+ url: String,
+ rebloggedUrl: String?,
+ isBot: Boolean,
+ statusDisplayOptions: StatusDisplayOptions,
+ ) {
+ val avatarRadius: Int
+ if (TextUtils.isEmpty(rebloggedUrl)) {
+ avatar.setPaddingRelative(0, 0, 0, 0)
+ if (statusDisplayOptions.showBotOverlay && isBot) {
+ avatarInset.visibility = View.VISIBLE
+ Glide.with(avatarInset)
+ .load(R.drawable.bot_badge)
+ .into(avatarInset)
+ } else {
+ avatarInset.visibility = View.GONE
+ }
+ avatarRadius = avatarRadius48dp
+ } else {
+ val padding = Utils.dpToPx(context, 12)
+ avatar.setPaddingRelative(0, 0, padding, padding)
+ avatarInset.visibility = View.VISIBLE
+ avatarInset.background = null
+ loadAvatar(
+ rebloggedUrl,
+ avatarInset,
+ avatarRadius24dp,
+ statusDisplayOptions.animateAvatars,
+ null,
+ )
+ avatarRadius = avatarRadius36dp
+ }
+ loadAvatar(
+ url,
+ avatar,
+ avatarRadius,
+ statusDisplayOptions.animateAvatars,
+ listOf(CompositeWithOpaqueBackground(avatar)),
+ )
+ }
+
+ protected open fun setMetaData(
+ statusViewData: StatusViewData,
+ statusDisplayOptions: StatusDisplayOptions,
+ listener: StatusActionListener,
+ ) {
+ val (_, _, _, _, _, _, _, createdAt, editedAt) = statusViewData.actionable
+ var timestampText: String
+ timestampText = if (statusDisplayOptions.useAbsoluteTime) {
+ absoluteTimeFormatter.format(createdAt, true)
+ } else {
+ val then = createdAt.time
+ val now = System.currentTimeMillis()
+ getRelativeTimeSpanString(context, then, now)
+ }
+ editedAt?.also {
+ timestampText = context.getString(
+ R.string.post_timestamp_with_edited_indicator,
+ timestampText,
+ )
+ }
+ metaInfo.text = timestampText
+ }
+
+ private fun getCreatedAtDescription(
+ createdAt: Date?,
+ statusDisplayOptions: StatusDisplayOptions,
+ ): CharSequence {
+ return if (statusDisplayOptions.useAbsoluteTime) {
+ absoluteTimeFormatter.format(createdAt, true)
+ } else {
+ /* This one is for screen-readers. Frequently, they would mispronounce timestamps like "17m"
+ * as 17 meters instead of minutes. */
+ createdAt?.let {
+ val then = createdAt.time
+ val now = System.currentTimeMillis()
+ DateUtils.getRelativeTimeSpanString(
+ then,
+ now,
+ DateUtils.SECOND_IN_MILLIS,
+ DateUtils.FORMAT_ABBREV_RELATIVE,
+ )
+ } ?: "? minutes"
+ }
+ }
+
+ protected fun setIsReply(isReply: Boolean) {
+ val drawable = if (isReply) R.drawable.ic_reply_all_24dp else R.drawable.ic_reply_24dp
+ replyButton.setImageResource(drawable)
+ }
+
+ private fun setReplyCount(repliesCount: Int, fullStats: Boolean) {
+ // This label only exists in the non-detailed view (to match the web ui)
+ replyCountLabel ?: return
+
+ if (fullStats) {
+ replyCountLabel.text = formatNumber(repliesCount.toLong(), 1000)
+ return
+ }
+
+ // Show "0", "1", or "1+" for replies otherwise, so the user knows if there is a thread
+ // that they can click through to read.
+ replyCountLabel.text =
+ if (repliesCount > 1) context.getString(R.string.status_count_one_plus) else repliesCount.toString()
+ }
+
+ private fun setReblogged(reblogged: Boolean) {
+ reblogButton!!.isChecked = reblogged
+ }
+
+ // This should only be called after setReblogged, in order to override the tint correctly.
+ private fun setRebloggingEnabled(enabled: Boolean, visibility: Status.Visibility) {
+ reblogButton!!.isEnabled = enabled && visibility !== Status.Visibility.PRIVATE
+ if (enabled) {
+ val inactiveId: Int
+ val activeId: Int
+ if (visibility === Status.Visibility.PRIVATE) {
+ inactiveId = R.drawable.ic_reblog_private_24dp
+ activeId = R.drawable.ic_reblog_private_active_24dp
+ } else {
+ inactiveId = R.drawable.ic_reblog_24dp
+ activeId = R.drawable.ic_reblog_active_24dp
+ }
+ reblogButton.setInactiveImage(inactiveId)
+ reblogButton.setActiveImage(activeId)
+ return
+ }
+
+ val disabledId: Int = if (visibility === Status.Visibility.DIRECT) {
+ R.drawable.ic_reblog_direct_24dp
+ } else {
+ R.drawable.ic_reblog_private_24dp
+ }
+ reblogButton.setInactiveImage(disabledId)
+ reblogButton.setActiveImage(disabledId)
+ }
+
+ protected fun setFavourited(favourited: Boolean) {
+ favouriteButton.isChecked = favourited
+ }
+
+ protected fun setBookmarked(bookmarked: Boolean) {
+ bookmarkButton.isChecked = bookmarked
+ }
+
+ private fun decodeBlurHash(blurhash: String): BitmapDrawable {
+ return decodeBlurHash(context, blurhash)
+ }
+
+ private fun loadImage(
+ imageView: MediaPreviewImageView,
+ previewUrl: String?,
+ focus: Attachment.Focus?,
+ blurhash: String?,
+ ) {
+ val placeholder = blurhash?.let { decodeBlurHash(it) } ?: mediaPreviewUnloaded
+ if (TextUtils.isEmpty(previewUrl)) {
+ imageView.removeFocalPoint()
+ Glide.with(imageView)
+ .load(placeholder)
+ .centerInside()
+ .into(imageView)
+ return
+ }
+
+ if (focus != null) { // If there is a focal point for this attachment:
+ imageView.setFocalPoint(focus)
+ Glide.with(context)
+ .load(previewUrl)
+ .placeholder(placeholder)
+ .centerInside()
+ .addListener(imageView)
+ .into(imageView)
+ } else {
+ imageView.removeFocalPoint()
+ Glide.with(imageView)
+ .load(previewUrl)
+ .placeholder(placeholder)
+ .centerInside()
+ .into(imageView)
+ }
+ }
+
+ protected fun setMediaPreviews(
+ attachments: List,
+ sensitive: Boolean,
+ listener: StatusActionListener,
+ showingContent: Boolean,
+ useBlurhash: Boolean,
+ ) {
+ mediaPreview.visibility = View.VISIBLE
+ mediaPreview.aspectRatios = attachments.aspectRatios()
+ mediaPreview.forEachIndexed { i: Int, imageView: MediaPreviewImageView, descriptionIndicator: TextView ->
+ val attachment = attachments[i]
+ val previewUrl = attachment.previewUrl
+ val description = attachment.description
+ val hasDescription = !TextUtils.isEmpty(description)
+ if (hasDescription) {
+ imageView.contentDescription = description
+ } else {
+ imageView.contentDescription = context.getString(R.string.action_view_media)
+ }
+ loadImage(
+ imageView,
+ if (showingContent) previewUrl else null,
+ attachment.meta?.focus,
+ if (useBlurhash) attachment.blurhash else null,
+ )
+ val type = attachment.type
+ if (showingContent && (type === Attachment.Type.VIDEO || type === Attachment.Type.GIFV)) {
+ imageView.foreground =
+ ContextCompat.getDrawable(context, R.drawable.play_indicator_overlay)
+ } else {
+ imageView.foreground = null
+ }
+ setAttachmentClickListener(imageView, listener, i, attachment, true)
+ if (sensitive) {
+ sensitiveMediaWarning.setText(R.string.post_sensitive_media_title)
+ } else {
+ sensitiveMediaWarning.setText(R.string.post_media_hidden_title)
+ }
+ sensitiveMediaWarning.visibility = if (showingContent) View.GONE else View.VISIBLE
+ sensitiveMediaShow.visibility = if (showingContent) View.VISIBLE else View.GONE
+ descriptionIndicator.visibility =
+ if (hasDescription && showingContent) View.VISIBLE else View.GONE
+ sensitiveMediaShow.setOnClickListener { v: View ->
+ if (bindingAdapterPosition != RecyclerView.NO_POSITION) {
+ listener.onContentHiddenChange(false, bindingAdapterPosition)
+ }
+ v.visibility = View.GONE
+ sensitiveMediaWarning.visibility = View.VISIBLE
+ descriptionIndicator.visibility = View.GONE
+ }
+ sensitiveMediaWarning.setOnClickListener { v: View ->
+ if (bindingAdapterPosition != RecyclerView.NO_POSITION) {
+ listener.onContentHiddenChange(true, bindingAdapterPosition)
+ }
+ v.visibility = View.GONE
+ sensitiveMediaShow.visibility = View.VISIBLE
+ descriptionIndicator.visibility = if (hasDescription) View.VISIBLE else View.GONE
+ }
+ }
+ }
+
+ private fun updateMediaLabel(index: Int, sensitive: Boolean, showingContent: Boolean) {
+ val label =
+ if (sensitive && !showingContent) context.getString(R.string.post_sensitive_media_title) else mediaDescriptions[index]
+ mediaLabels[index].text = label
+ }
+
+ protected fun setMediaLabel(
+ attachments: List,
+ sensitive: Boolean,
+ listener: StatusActionListener,
+ showingContent: Boolean,
+ ) {
+ for (i in mediaLabels.indices) {
+ val mediaLabel = mediaLabels[i]
+ if (i < attachments.size) {
+ val attachment = attachments[i]
+ mediaLabel.visibility = View.VISIBLE
+ mediaDescriptions[i] = attachment.getFormattedDescription(context)
+ updateMediaLabel(i, sensitive, showingContent)
+
+ // Set the icon next to the label.
+ val drawableId = attachments[0].iconResource()
+ mediaLabel.setCompoundDrawablesWithIntrinsicBounds(drawableId, 0, 0, 0)
+ setAttachmentClickListener(mediaLabel, listener, i, attachment, false)
+ } else {
+ mediaLabel.visibility = View.GONE
+ }
+ }
+ }
+
+ private fun setAttachmentClickListener(
+ view: View,
+ listener: StatusActionListener,
+ index: Int,
+ attachment: Attachment,
+ animateTransition: Boolean,
+ ) {
+ view.setOnClickListener { v: View? ->
+ val position = bindingAdapterPosition.takeIf { it != RecyclerView.NO_POSITION } ?: return@setOnClickListener
+ if (sensitiveMediaWarning.visibility == View.VISIBLE) {
+ listener.onContentHiddenChange(true, bindingAdapterPosition)
+ } else {
+ listener.onViewMedia(position, index, if (animateTransition) v else null)
+ }
+ }
+ view.setOnLongClickListener {
+ val description = attachment.getFormattedDescription(view.context)
+ Toast.makeText(view.context, description, Toast.LENGTH_LONG).show()
+ true
+ }
+ }
+
+ protected fun hideSensitiveMediaWarning() {
+ sensitiveMediaWarning.hide()
+ sensitiveMediaShow.hide()
+ }
+
+ protected fun setupButtons(
+ listener: StatusActionListener,
+ accountId: String,
+ statusDisplayOptions: StatusDisplayOptions,
+ ) {
+ val profileButtonClickListener = View.OnClickListener { listener.onViewAccount(accountId) }
+ avatar.setOnClickListener(profileButtonClickListener)
+ displayName.setOnClickListener(profileButtonClickListener)
+ replyButton.setOnClickListener {
+ val position = bindingAdapterPosition.takeIf { it != RecyclerView.NO_POSITION } ?: return@setOnClickListener
+ listener.onReply(position)
+ }
+ reblogButton?.setEventListener { _: SparkButton?, buttonState: Boolean ->
+ // return true to play animation
+ val position = bindingAdapterPosition.takeIf { it != RecyclerView.NO_POSITION } ?: return@setEventListener false
+ return@setEventListener if (statusDisplayOptions.confirmReblogs) {
+ showConfirmReblog(listener, buttonState, position)
+ false
+ } else {
+ listener.onReblog(!buttonState, position)
+ true
+ }
+ }
+ favouriteButton.setEventListener { _: SparkButton?, buttonState: Boolean ->
+ // return true to play animation
+ val position = bindingAdapterPosition.takeIf { it != RecyclerView.NO_POSITION } ?: return@setEventListener true
+ return@setEventListener if (statusDisplayOptions.confirmFavourites) {
+ showConfirmFavourite(listener, buttonState, position)
+ false
+ } else {
+ listener.onFavourite(!buttonState, position)
+ true
+ }
+ }
+ bookmarkButton.setEventListener { _: SparkButton?, buttonState: Boolean ->
+ val position = bindingAdapterPosition.takeIf { it != RecyclerView.NO_POSITION } ?: return@setEventListener true
+ listener.onBookmark(!buttonState, position)
+ true
+ }
+ moreButton.setOnClickListener { v: View? ->
+ val position = bindingAdapterPosition.takeIf { it != RecyclerView.NO_POSITION } ?: return@setOnClickListener
+ listener.onMore(v!!, position)
+ }
+
+ /* Even though the content TextView is a child of the container, it won't respond to clicks
+ * if it contains URLSpans without also setting its listener. The surrounding spans will
+ * just eat the clicks instead of deferring to the parent listener, but WILL respond to a
+ * listener directly on the TextView, for whatever reason. */
+ val viewThreadListener = View.OnClickListener {
+ val position = bindingAdapterPosition.takeIf { it != RecyclerView.NO_POSITION } ?: return@OnClickListener
+ listener.onViewThread(position)
+ }
+
+ content.setOnClickListener(viewThreadListener)
+ itemView.setOnClickListener(viewThreadListener)
+ }
+
+ private fun showConfirmReblog(
+ listener: StatusActionListener,
+ buttonState: Boolean,
+ position: Int,
+ ) {
+ val popup = PopupMenu(context, reblogButton!!)
+ popup.inflate(R.menu.status_reblog)
+ val menu = popup.menu
+ if (buttonState) {
+ menu.findItem(R.id.menu_action_reblog).isVisible = false
+ } else {
+ menu.findItem(R.id.menu_action_unreblog).isVisible = false
+ }
+ popup.setOnMenuItemClickListener {
+ listener.onReblog(!buttonState, position)
+ if (!buttonState) {
+ reblogButton.playAnimation()
+ }
+ true
+ }
+ popup.show()
+ }
+
+ private fun showConfirmFavourite(
+ listener: StatusActionListener,
+ buttonState: Boolean,
+ position: Int,
+ ) {
+ val popup = PopupMenu(context, favouriteButton)
+ popup.inflate(R.menu.status_favourite)
+ val menu = popup.menu
+ if (buttonState) {
+ menu.findItem(R.id.menu_action_favourite).isVisible = false
+ } else {
+ menu.findItem(R.id.menu_action_unfavourite).isVisible = false
+ }
+ popup.setOnMenuItemClickListener {
+ listener.onFavourite(!buttonState, position)
+ if (!buttonState) {
+ favouriteButton.playAnimation()
+ }
+ true
+ }
+ popup.show()
+ }
+
+ open fun setupWithStatus(
+ status: StatusViewData,
+ listener: StatusActionListener,
+ statusDisplayOptions: StatusDisplayOptions,
+ payloads: Any? = null,
+ ) {
+ if (payloads == null) {
+ val actionable = status.actionable
+ setDisplayName(actionable.account.name, actionable.account.emojis, statusDisplayOptions)
+ setUsername(status.username)
+ setMetaData(status, statusDisplayOptions, listener)
+ setIsReply(actionable.inReplyToId != null)
+ setReplyCount(actionable.repliesCount, statusDisplayOptions.showStatsInline)
+ setAvatar(
+ actionable.account.avatar,
+ status.rebloggedAvatar,
+ actionable.account.bot,
+ statusDisplayOptions,
+ )
+ setReblogged(actionable.reblogged)
+ setFavourited(actionable.favourited)
+ setBookmarked(actionable.bookmarked)
+ val attachments = actionable.attachments
+ val sensitive = actionable.sensitive
+ if (statusDisplayOptions.mediaPreviewEnabled && hasPreviewableAttachment(attachments)) {
+ setMediaPreviews(
+ attachments,
+ sensitive,
+ listener,
+ status.isShowingContent,
+ statusDisplayOptions.useBlurhash,
+ )
+ if (attachments.isEmpty()) {
+ hideSensitiveMediaWarning()
+ }
+ // Hide the unused label.
+ for (mediaLabel in mediaLabels) {
+ mediaLabel.visibility = View.GONE
+ }
+ } else {
+ setMediaLabel(attachments, sensitive, listener, status.isShowingContent)
+ // Hide all unused views.
+ mediaPreview.visibility = View.GONE
+ hideSensitiveMediaWarning()
+ }
+ setupCard(
+ status,
+ status.isExpanded,
+ statusDisplayOptions.cardViewMode,
+ statusDisplayOptions,
+ listener,
+ )
+ setupButtons(
+ listener,
+ actionable.account.id,
+ statusDisplayOptions,
+ )
+ setRebloggingEnabled(actionable.rebloggingAllowed(), actionable.visibility)
+ setSpoilerAndContent(status, statusDisplayOptions, listener)
+ setDescriptionForStatus(status, statusDisplayOptions)
+
+ // Workaround for RecyclerView 1.0.0 / androidx.core 1.0.0
+ // RecyclerView tries to set AccessibilityDelegateCompat to null
+ // but ViewCompat code replaces is with the default one. RecyclerView never
+ // fetches another one from its delegate because it checks that it's set so we remove it
+ // and let RecyclerView ask for a new delegate.
+ itemView.accessibilityDelegate = null
+ } else {
+ if (payloads is List<*>) {
+ for (item in payloads) {
+ if (Key.KEY_CREATED == item) {
+ setMetaData(status, statusDisplayOptions, listener)
+ }
+ }
+ }
+ }
+ }
+
+ private fun setDescriptionForStatus(
+ status: StatusViewData,
+ statusDisplayOptions: StatusDisplayOptions,
+ ) {
+ val (_, _, account, _, _, _, _, createdAt, editedAt, _, reblogsCount, favouritesCount, _, reblogged, favourited, bookmarked, sensitive, _, visibility) = status.actionable
+ val description = context.getString(
+ R.string.description_status,
+ account.displayName,
+ getContentWarningDescription(context, status),
+ if (TextUtils.isEmpty(status.spoilerText) || !sensitive || status.isExpanded) status.content else "",
+ getCreatedAtDescription(createdAt, statusDisplayOptions),
+ editedAt?.let { context.getString(R.string.description_post_edited) } ?: "",
+ getReblogDescription(context, status),
+ status.username,
+ if (reblogged) context.getString(R.string.description_post_reblogged) else "",
+ if (favourited) context.getString(R.string.description_post_favourited) else "",
+ if (bookmarked) context.getString(R.string.description_post_bookmarked) else "",
+ getMediaDescription(context, status),
+ visibility.description(context),
+ getFavsText(favouritesCount),
+ getReblogsText(reblogsCount),
+ status.actionable.poll?.let {
+ pollView.getPollDescription(
+ from(it),
+ statusDisplayOptions,
+ numberFormat,
+ absoluteTimeFormatter,
+ )
+ } ?: "",
+ )
+ itemView.contentDescription = description
+ }
+
+ protected fun getFavsText(count: Int): CharSequence {
+ if (count <= 0) return ""
+
+ val countString = numberFormat.format(count.toLong())
+ return HtmlCompat.fromHtml(
+ context.resources.getQuantityString(R.plurals.favs, count, countString),
+ HtmlCompat.FROM_HTML_MODE_LEGACY,
+ )
+ }
+
+ protected fun getReblogsText(count: Int): CharSequence {
+ if (count <= 0) return ""
+
+ val countString = numberFormat.format(count.toLong())
+ return HtmlCompat.fromHtml(
+ context.resources.getQuantityString(
+ R.plurals.reblogs,
+ count,
+ countString,
+ ),
+ HtmlCompat.FROM_HTML_MODE_LEGACY,
+ )
+ }
+
+ protected fun setupCard(
+ status: StatusViewData,
+ expanded: Boolean,
+ cardViewMode: CardViewMode,
+ statusDisplayOptions: StatusDisplayOptions,
+ listener: StatusActionListener,
+ ) {
+ cardView ?: return
+
+ val (_, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, sensitive, _, _, attachments, _, _, _, _, _, poll, card) = status.actionable
+ if (cardViewMode !== CardViewMode.NONE && attachments.isEmpty() && poll == null && card != null &&
+ !TextUtils.isEmpty(card.url) &&
+ (!sensitive || expanded) &&
+ (!status.isCollapsible || !status.isCollapsed)
+ ) {
+ cardView.visibility = View.VISIBLE
+ cardView.bind(card, status.actionable.sensitive, statusDisplayOptions) { target ->
+ if (card.kind == PreviewCardKind.PHOTO && card.embedUrl.isNotEmpty() && target == PreviewCardView.Target.IMAGE) {
+ context.startActivity(
+ newSingleImageIntent(context, card.embedUrl),
+ )
+ } else {
+ listener.onViewUrl(card.url)
+ }
+ }
+ } else {
+ cardView.visibility = View.GONE
+ }
+ }
+
+ open fun showStatusContent(show: Boolean) {
+ val visibility = if (show) View.VISIBLE else View.GONE
+ avatar.visibility = visibility
+ avatarInset.visibility = visibility
+ displayName.visibility = visibility
+ username.visibility = visibility
+ metaInfo.visibility = visibility
+ contentWarningDescription.visibility = visibility
+ contentWarningButton.visibility = visibility
+ content.visibility = visibility
+ cardView!!.visibility = visibility
+ mediaContainer.visibility = visibility
+ pollView.visibility = visibility
+ replyButton.visibility = visibility
+ reblogButton!!.visibility = visibility
+ favouriteButton.visibility = visibility
+ bookmarkButton.visibility = visibility
+ moreButton.visibility = visibility
+ }
+
+ companion object {
+ private const val TAG = "StatusBaseViewHolder"
+
+ @JvmStatic
+ protected fun hasPreviewableAttachment(attachments: List): Boolean {
+ for ((_, _, _, _, type) in attachments) {
+ if (type === Attachment.Type.AUDIO || type === Attachment.Type.UNKNOWN) {
+ return false
+ }
+ }
+ return true
+ }
+
+ private fun getReblogDescription(context: Context, status: StatusViewData): CharSequence {
+ return status.rebloggingStatus?.let {
+ context.getString(R.string.post_boosted_format, it.account.username)
+ } ?: ""
+ }
+
+ private fun getMediaDescription(context: Context, status: StatusViewData): CharSequence {
+ if (status.actionable.attachments.isEmpty()) return ""
+
+ val mediaDescriptions =
+ status.actionable.attachments.fold(StringBuilder()) { builder: StringBuilder, (_, _, _, _, _, description): Attachment ->
+ if (description == null) {
+ val placeholder =
+ context.getString(R.string.description_post_media_no_description_placeholder)
+ return@fold builder.append(placeholder)
+ } else {
+ builder.append("; ")
+ return@fold builder.append(description)
+ }
+ }
+ return context.getString(R.string.description_post_media, mediaDescriptions)
+ }
+
+ private fun getContentWarningDescription(context: Context, status: StatusViewData): CharSequence {
+ return if (!TextUtils.isEmpty(status.spoilerText)) {
+ context.getString(R.string.description_post_cw, status.spoilerText)
+ } else {
+ ""
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/app/pachli/adapter/StatusDetailedViewHolder.java b/app/src/main/java/app/pachli/adapter/StatusDetailedViewHolder.java
deleted file mode 100644
index 17fb5654e..000000000
--- a/app/src/main/java/app/pachli/adapter/StatusDetailedViewHolder.java
+++ /dev/null
@@ -1,216 +0,0 @@
-package app.pachli.adapter;
-
-import android.content.Context;
-import android.graphics.drawable.Drawable;
-import android.os.Build;
-import android.text.SpannableStringBuilder;
-import android.text.Spanned;
-import android.text.method.LinkMovementMethod;
-import android.text.style.DynamicDrawableSpan;
-import android.text.style.ImageSpan;
-import android.view.View;
-import android.widget.TextView;
-
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
-import androidx.appcompat.content.res.AppCompatResources;
-import androidx.recyclerview.widget.RecyclerView;
-
-import java.text.DateFormat;
-import java.util.Date;
-
-import app.pachli.R;
-import app.pachli.entity.Status;
-import app.pachli.interfaces.StatusActionListener;
-import app.pachli.util.CardViewMode;
-import app.pachli.util.LinkHelper;
-import app.pachli.util.NoUnderlineURLSpan;
-import app.pachli.util.StatusDisplayOptions;
-import app.pachli.viewdata.StatusViewData;
-
-public class StatusDetailedViewHolder extends StatusBaseViewHolder {
- private final TextView reblogs;
- private final TextView favourites;
- private final View infoDivider;
-
- private static final DateFormat dateFormat = DateFormat.getDateTimeInstance(DateFormat.DEFAULT, DateFormat.SHORT);
-
- public StatusDetailedViewHolder(@NonNull View view) {
- super(view);
- reblogs = view.findViewById(R.id.status_reblogs);
- favourites = view.findViewById(R.id.status_favourites);
- infoDivider = view.findViewById(R.id.status_info_divider);
- }
-
- @Override
- protected void setMetaData(@NonNull StatusViewData statusViewData, @NonNull StatusDisplayOptions statusDisplayOptions, @NonNull StatusActionListener listener) {
-
- Status status = statusViewData.getActionable();
-
- Status.Visibility visibility = status.getVisibility();
- Context context = metaInfo.getContext();
-
- Drawable visibilityIcon = getVisibilityIcon(visibility);
- CharSequence visibilityString = getVisibilityDescription(context, visibility);
-
- SpannableStringBuilder sb = new SpannableStringBuilder(visibilityString);
-
- if (visibilityIcon != null) {
- ImageSpan visibilityIconSpan = new ImageSpan(
- visibilityIcon,
- Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q ? DynamicDrawableSpan.ALIGN_CENTER : DynamicDrawableSpan.ALIGN_BASELINE
- );
- sb.setSpan(visibilityIconSpan, 0, visibilityString.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
- }
-
- String metadataJoiner = context.getString(R.string.metadata_joiner);
-
- Date createdAt = status.getCreatedAt();
- if (createdAt != null) {
-
- sb.append(" ");
- sb.append(dateFormat.format(createdAt));
- }
-
- Date editedAt = status.getEditedAt();
-
- if (editedAt != null) {
- String editedAtString = context.getString(R.string.post_edited, dateFormat.format(editedAt));
-
- sb.append(metadataJoiner);
- int spanStart = sb.length();
- int spanEnd = spanStart + editedAtString.length();
-
- sb.append(editedAtString);
-
- if (statusViewData.getStatus().getEditedAt() != null) {
- NoUnderlineURLSpan editedClickSpan = new NoUnderlineURLSpan("") {
- @Override
- public void onClick(@NonNull View view) {
- listener.onShowEdits(getBindingAdapterPosition());
- }
- };
-
- sb.setSpan(editedClickSpan, spanStart, spanEnd, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
- }
- }
-
- Status.Application app = status.getApplication();
-
- if (app != null) {
-
- sb.append(metadataJoiner);
-
- if (app.getWebsite() != null) {
- CharSequence text = LinkHelper.createClickableText(app.getName(), app.getWebsite());
- sb.append(text);
- } else {
- sb.append(app.getName());
- }
- }
-
- metaInfo.setMovementMethod(LinkMovementMethod.getInstance());
- metaInfo.setText(sb);
- }
-
- private void setReblogAndFavCount(int reblogCount, int favCount, @NonNull StatusActionListener listener) {
-
- if (reblogCount > 0) {
- reblogs.setText(getReblogsText(reblogs.getContext(), reblogCount));
- reblogs.setVisibility(View.VISIBLE);
- } else {
- reblogs.setVisibility(View.GONE);
- }
- if (favCount > 0) {
- favourites.setText(getFavsText(favourites.getContext(), favCount));
- favourites.setVisibility(View.VISIBLE);
- } else {
- favourites.setVisibility(View.GONE);
- }
-
- if (reblogs.getVisibility() == View.GONE && favourites.getVisibility() == View.GONE) {
- infoDivider.setVisibility(View.GONE);
- } else {
- infoDivider.setVisibility(View.VISIBLE);
- }
-
- reblogs.setOnClickListener(v -> {
- int position = getBindingAdapterPosition();
- if (position != RecyclerView.NO_POSITION) {
- listener.onShowReblogs(position);
- }
- });
- favourites.setOnClickListener(v -> {
- int position = getBindingAdapterPosition();
- if (position != RecyclerView.NO_POSITION) {
- listener.onShowFavs(position);
- }
- });
- }
-
- @Override
- public void setupWithStatus(@NonNull final StatusViewData status,
- @NonNull final StatusActionListener listener,
- @NonNull StatusDisplayOptions statusDisplayOptions,
- @Nullable Object payloads) {
- // We never collapse statuses in the detail view
- StatusViewData uncollapsedStatus = (status.isCollapsible() && status.isCollapsed()) ?
- status.copyWithCollapsed(false) :
- status;
-
- super.setupWithStatus(uncollapsedStatus, listener, statusDisplayOptions, payloads);
- setupCard(uncollapsedStatus, status.isExpanded(), CardViewMode.FULL_WIDTH, statusDisplayOptions, listener); // Always show card for detailed status
- if (payloads == null) {
- Status actionable = uncollapsedStatus.getActionable();
-
- if (!statusDisplayOptions.hideStats()) {
- setReblogAndFavCount(actionable.getReblogsCount(),
- actionable.getFavouritesCount(), listener);
- } else {
- hideQuantitativeStats();
- }
- }
- }
-
- private @Nullable Drawable getVisibilityIcon(@Nullable Status.Visibility visibility) {
-
- if (visibility == null) {
- return null;
- }
-
- int visibilityIcon;
- switch (visibility) {
- case PUBLIC -> visibilityIcon = R.drawable.ic_public_24dp;
- case UNLISTED -> visibilityIcon = R.drawable.ic_lock_open_24dp;
- case PRIVATE -> visibilityIcon = R.drawable.ic_lock_outline_24dp;
- case DIRECT -> visibilityIcon = R.drawable.ic_email_24dp;
- default -> {
- return null;
- }
- }
-
- final Drawable visibilityDrawable = AppCompatResources.getDrawable(
- this.metaInfo.getContext(), visibilityIcon
- );
- if (visibilityDrawable == null) {
- return null;
- }
-
- final int size = (int) this.metaInfo.getTextSize();
- visibilityDrawable.setBounds(
- 0,
- 0,
- size,
- size
- );
- visibilityDrawable.setTint(this.metaInfo.getCurrentTextColor());
-
- return visibilityDrawable;
- }
-
- private void hideQuantitativeStats() {
- reblogs.setVisibility(View.GONE);
- favourites.setVisibility(View.GONE);
- infoDivider.setVisibility(View.GONE);
- }
-}
diff --git a/app/src/main/java/app/pachli/adapter/StatusDetailedViewHolder.kt b/app/src/main/java/app/pachli/adapter/StatusDetailedViewHolder.kt
new file mode 100644
index 000000000..6549bb216
--- /dev/null
+++ b/app/src/main/java/app/pachli/adapter/StatusDetailedViewHolder.kt
@@ -0,0 +1,142 @@
+package app.pachli.adapter
+
+import android.os.Build
+import android.text.SpannableStringBuilder
+import android.text.Spanned
+import android.text.method.LinkMovementMethod
+import android.text.style.DynamicDrawableSpan
+import android.text.style.ImageSpan
+import android.view.View
+import androidx.recyclerview.widget.RecyclerView
+import app.pachli.R
+import app.pachli.databinding.ItemStatusDetailedBinding
+import app.pachli.entity.description
+import app.pachli.entity.icon
+import app.pachli.interfaces.StatusActionListener
+import app.pachli.util.CardViewMode
+import app.pachli.util.NoUnderlineURLSpan
+import app.pachli.util.StatusDisplayOptions
+import app.pachli.util.createClickableText
+import app.pachli.util.hide
+import app.pachli.util.show
+import app.pachli.viewdata.StatusViewData
+import java.text.DateFormat
+
+class StatusDetailedViewHolder(
+ private val binding: ItemStatusDetailedBinding,
+) : StatusBaseViewHolder(binding.root) {
+
+ override fun setMetaData(
+ statusViewData: StatusViewData,
+ statusDisplayOptions: StatusDisplayOptions,
+ listener: StatusActionListener,
+ ) {
+ val (_, _, _, _, _, _, _, createdAt, editedAt, _, _, _, _, _, _, _, _, _, visibility, _, _, _, app) = statusViewData.actionable
+ val visibilityIcon = visibility.icon(metaInfo)
+ val visibilityString = visibility.description(context)
+ val sb = SpannableStringBuilder(visibilityString)
+ visibilityIcon?.also {
+ val alignment = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) DynamicDrawableSpan.ALIGN_CENTER else DynamicDrawableSpan.ALIGN_BASELINE
+ val visibilityIconSpan = ImageSpan(it, alignment)
+ sb.setSpan(visibilityIconSpan, 0, visibilityString.length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
+ }
+ val metadataJoiner = context.getString(R.string.metadata_joiner)
+ sb.append(" ")
+ sb.append(dateFormat.format(createdAt))
+
+ editedAt?.also {
+ val editedAtString = context.getString(R.string.post_edited, dateFormat.format(it))
+ sb.append(metadataJoiner)
+ val spanStart = sb.length
+ val spanEnd = spanStart + editedAtString.length
+ sb.append(editedAtString)
+ statusViewData.status.editedAt?.also {
+ val editedClickSpan: NoUnderlineURLSpan = object : NoUnderlineURLSpan("") {
+ override fun onClick(view: View) {
+ listener.onShowEdits(bindingAdapterPosition)
+ }
+ }
+ sb.setSpan(editedClickSpan, spanStart, spanEnd, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
+ }
+ }
+
+ app?.also { application ->
+ val (name, website) = application
+ sb.append(metadataJoiner)
+ website?.also { sb.append(createClickableText(name, it)) ?: sb.append(name) }
+ }
+
+ metaInfo.movementMethod = LinkMovementMethod.getInstance()
+ metaInfo.text = sb
+ }
+
+ private fun setReblogAndFavCount(
+ reblogCount: Int,
+ favCount: Int,
+ listener: StatusActionListener,
+ ) {
+ if (reblogCount > 0) {
+ binding.statusReblogs.text = getReblogsText(reblogCount)
+ binding.statusReblogs.show()
+ } else {
+ binding.statusReblogs.hide()
+ }
+ if (favCount > 0) {
+ binding.statusFavourites.text = getFavsText(favCount)
+ binding.statusFavourites.show()
+ } else {
+ binding.statusFavourites.hide()
+ }
+ if (binding.statusReblogs.visibility == View.GONE && binding.statusFavourites.visibility == View.GONE) {
+ binding.statusInfoDivider.hide()
+ } else {
+ binding.statusInfoDivider.show()
+ }
+ binding.statusReblogs.setOnClickListener {
+ val position = bindingAdapterPosition.takeIf { it != RecyclerView.NO_POSITION } ?: return@setOnClickListener
+ listener.onShowReblogs(position)
+ }
+ binding.statusFavourites.setOnClickListener {
+ val position = bindingAdapterPosition.takeIf { it != RecyclerView.NO_POSITION } ?: return@setOnClickListener
+ listener.onShowFavs(position)
+ }
+ }
+
+ override fun setupWithStatus(
+ status: StatusViewData,
+ listener: StatusActionListener,
+ statusDisplayOptions: StatusDisplayOptions,
+ payloads: Any?,
+ ) {
+ // We never collapse statuses in the detail view
+ val uncollapsedStatus =
+ if (status.isCollapsible && status.isCollapsed) status.copyWithCollapsed(false) else status
+ super.setupWithStatus(uncollapsedStatus, listener, statusDisplayOptions, payloads)
+ setupCard(
+ uncollapsedStatus,
+ status.isExpanded,
+ CardViewMode.FULL_WIDTH,
+ statusDisplayOptions,
+ listener,
+ ) // Always show card for detailed status
+ if (payloads == null) {
+ val (_, _, _, _, _, _, _, _, _, _, reblogsCount, favouritesCount) = uncollapsedStatus.actionable
+ if (!statusDisplayOptions.hideStats) {
+ setReblogAndFavCount(reblogsCount, favouritesCount, listener)
+ } else {
+ hideQuantitativeStats()
+ }
+ }
+ }
+
+ private fun hideQuantitativeStats() {
+ binding.statusReblogs.hide()
+ binding.statusFavourites.hide()
+ binding.statusInfoDivider.hide()
+ }
+
+ companion object {
+ private val dateFormat =
+ DateFormat.getDateTimeInstance(DateFormat.DEFAULT, DateFormat.SHORT)
+ }
+}
diff --git a/app/src/main/java/app/pachli/adapter/StatusViewHolder.java b/app/src/main/java/app/pachli/adapter/StatusViewHolder.java
deleted file mode 100644
index 945f6b596..000000000
--- a/app/src/main/java/app/pachli/adapter/StatusViewHolder.java
+++ /dev/null
@@ -1,169 +0,0 @@
-/* Copyright 2017 Andrew Dawson
- *
- * This file is a part of Pachli.
- *
- * 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.
- *
- * Pachli 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 app.pachli.adapter;
-
-import android.content.Context;
-import android.text.InputFilter;
-import android.text.TextUtils;
-import android.view.View;
-import android.widget.Button;
-import android.widget.TextView;
-
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
-import androidx.recyclerview.widget.RecyclerView;
-
-import java.util.List;
-
-import app.pachli.R;
-import app.pachli.entity.Emoji;
-import app.pachli.entity.Filter;
-import app.pachli.entity.Status;
-import app.pachli.interfaces.StatusActionListener;
-import app.pachli.util.CustomEmojiHelper;
-import app.pachli.util.NumberUtils;
-import app.pachli.util.SmartLengthInputFilter;
-import app.pachli.util.StatusDisplayOptions;
-import app.pachli.util.StringUtils;
-import app.pachli.viewdata.StatusViewData;
-import at.connyduck.sparkbutton.helpers.Utils;
-
-public class StatusViewHolder extends StatusBaseViewHolder {
- private static final InputFilter[] COLLAPSE_INPUT_FILTER = new InputFilter[]{SmartLengthInputFilter.INSTANCE};
- private static final InputFilter[] NO_INPUT_FILTER = new InputFilter[0];
-
- private final TextView statusInfo;
- private final Button contentCollapseButton;
- private final TextView favouritedCountLabel;
- private final TextView reblogsCountLabel;
-
- public StatusViewHolder(@NonNull View itemView) {
- super(itemView);
- statusInfo = itemView.findViewById(R.id.status_info);
- contentCollapseButton = itemView.findViewById(R.id.button_toggle_content);
- favouritedCountLabel = itemView.findViewById(R.id.status_favourites_count);
- reblogsCountLabel = itemView.findViewById(R.id.status_insets);
- }
-
- @Override
- public void setupWithStatus(@NonNull StatusViewData status,
- @NonNull final StatusActionListener listener,
- @NonNull StatusDisplayOptions statusDisplayOptions,
- @Nullable Object payloads) {
- 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) {
- hideStatusInfo();
- } else {
- String rebloggedByDisplayName = reblogging.getAccount().getName();
- setRebloggedByDisplayName(rebloggedByDisplayName,
- reblogging.getAccount().getEmojis(), statusDisplayOptions);
- statusInfo.setOnClickListener(v -> listener.onOpenReblog(getBindingAdapterPosition()));
- }
-
- }
-
- reblogsCountLabel.setVisibility(statusDisplayOptions.showStatsInline() ? View.VISIBLE : View.INVISIBLE);
- favouritedCountLabel.setVisibility(statusDisplayOptions.showStatsInline() ? View.VISIBLE : View.INVISIBLE);
- setFavouritedCount(status.getActionable().getFavouritesCount());
- setReblogsCount(status.getActionable().getReblogsCount());
-
- super.setupWithStatus(status, listener, statusDisplayOptions, payloads);
- }
-
- private void setRebloggedByDisplayName(@NonNull final CharSequence name,
- final List accountEmoji,
- @NonNull 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);
- statusInfo.setVisibility(View.VISIBLE);
- }
-
- // don't use this on the same ViewHolder as setRebloggedByDisplayName, 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);
- statusInfo.setCompoundDrawablePadding(Utils.dpToPx(statusInfo.getContext(), 10));
- statusInfo.setPaddingRelative(Utils.dpToPx(statusInfo.getContext(), 28), 0, 0, 0);
- statusInfo.setVisibility(View.VISIBLE);
- }
-
- protected void setReblogsCount(int reblogsCount) {
- reblogsCountLabel.setText(NumberUtils.formatNumber(reblogsCount, 1000));
- }
-
- protected void setFavouritedCount(int favouritedCount) {
- favouritedCountLabel.setText(NumberUtils.formatNumber(favouritedCount, 1000));
- }
-
- protected void hideStatusInfo() {
- statusInfo.setVisibility(View.GONE);
- }
-
- private void setupCollapsedState(boolean sensitive,
- boolean expanded,
- @NonNull final StatusViewData status,
- @NonNull final StatusActionListener listener) {
- /* input filter for TextViews have to be set before text */
- if (status.isCollapsible() && (!sensitive || expanded)) {
- contentCollapseButton.setOnClickListener(view -> {
- int position = getBindingAdapterPosition();
- if (position != RecyclerView.NO_POSITION)
- listener.onContentCollapsedChange(!status.isCollapsed(), position);
- });
-
- contentCollapseButton.setVisibility(View.VISIBLE);
- if (status.isCollapsed()) {
- contentCollapseButton.setText(R.string.post_content_warning_show_more);
- content.setFilters(COLLAPSE_INPUT_FILTER);
- } else {
- contentCollapseButton.setText(R.string.post_content_warning_show_less);
- content.setFilters(NO_INPUT_FILTER);
- }
- } else {
- contentCollapseButton.setVisibility(View.GONE);
- content.setFilters(NO_INPUT_FILTER);
- }
- }
-
- public void showStatusContent(boolean show) {
- super.showStatusContent(show);
- contentCollapseButton.setVisibility(show ? View.VISIBLE : View.GONE);
- }
-
- @Override
- protected void toggleExpandedState(boolean sensitive,
- boolean expanded,
- @NonNull StatusViewData status,
- @NonNull StatusDisplayOptions statusDisplayOptions,
- @NonNull final StatusActionListener listener) {
-
- setupCollapsedState(sensitive, expanded, status, listener);
-
- super.toggleExpandedState(sensitive, expanded, status, statusDisplayOptions, listener);
- }
-}
diff --git a/app/src/main/java/app/pachli/adapter/StatusViewHolder.kt b/app/src/main/java/app/pachli/adapter/StatusViewHolder.kt
new file mode 100644
index 000000000..a214ae9b1
--- /dev/null
+++ b/app/src/main/java/app/pachli/adapter/StatusViewHolder.kt
@@ -0,0 +1,160 @@
+/* Copyright 2017 Andrew Dawson
+ *
+ * This file is a part of Pachli.
+ *
+ * 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.
+ *
+ * Pachli 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 app.pachli.adapter
+
+import android.text.InputFilter
+import android.text.TextUtils
+import android.view.View
+import androidx.recyclerview.widget.RecyclerView
+import app.pachli.R
+import app.pachli.databinding.ItemStatusBinding
+import app.pachli.entity.Emoji
+import app.pachli.entity.Filter
+import app.pachli.interfaces.StatusActionListener
+import app.pachli.util.SmartLengthInputFilter
+import app.pachli.util.StatusDisplayOptions
+import app.pachli.util.emojify
+import app.pachli.util.formatNumber
+import app.pachli.util.hide
+import app.pachli.util.show
+import app.pachli.util.unicodeWrap
+import app.pachli.util.visible
+import app.pachli.viewdata.StatusViewData
+import at.connyduck.sparkbutton.helpers.Utils
+
+open class StatusViewHolder(
+ private val binding: ItemStatusBinding,
+ root: View? = null,
+) : StatusBaseViewHolder(root ?: binding.root) {
+
+ override fun setupWithStatus(
+ status: StatusViewData,
+ listener: StatusActionListener,
+ statusDisplayOptions: StatusDisplayOptions,
+ payloads: Any?,
+ ) = with(binding) {
+ if (payloads == null) {
+ val sensitive = !TextUtils.isEmpty(status.actionable.spoilerText)
+ val expanded = status.isExpanded
+ setupCollapsedState(sensitive, expanded, status, listener)
+ val reblogging = status.rebloggingStatus
+ if (reblogging == null || status.filterAction === Filter.Action.WARN) {
+ statusInfo.hide()
+ } else {
+ val rebloggedByDisplayName = reblogging.account.name
+ setRebloggedByDisplayName(
+ rebloggedByDisplayName,
+ reblogging.account.emojis,
+ statusDisplayOptions,
+ )
+ statusInfo.setOnClickListener {
+ listener.onOpenReblog(bindingAdapterPosition)
+ }
+ }
+ }
+ statusReblogsCount.visible(statusDisplayOptions.showStatsInline)
+ statusFavouritesCount.visible(statusDisplayOptions.showStatsInline)
+ setFavouritedCount(status.actionable.favouritesCount)
+ setReblogsCount(status.actionable.reblogsCount)
+ super.setupWithStatus(status, listener, statusDisplayOptions, payloads)
+ }
+
+ private fun setRebloggedByDisplayName(
+ name: CharSequence,
+ accountEmoji: List?,
+ statusDisplayOptions: StatusDisplayOptions,
+ ) = with(binding) {
+ val wrappedName: CharSequence = name.unicodeWrap()
+ val boostedText: CharSequence = context.getString(R.string.post_boosted_format, wrappedName)
+ val emojifiedText =
+ boostedText.emojify(accountEmoji, statusInfo, statusDisplayOptions.animateEmojis)
+ statusInfo.text = emojifiedText
+ statusInfo.show()
+ }
+
+ // don't use this on the same ViewHolder as setRebloggedByDisplayName, will cause recycling issues as paddings are changed
+ protected fun setPollInfo(ownPoll: Boolean) = with(binding) {
+ statusInfo.setText(if (ownPoll) R.string.poll_ended_created else R.string.poll_ended_voted)
+ statusInfo.setCompoundDrawablesRelativeWithIntrinsicBounds(R.drawable.ic_poll_24dp, 0, 0, 0)
+ statusInfo.compoundDrawablePadding =
+ Utils.dpToPx(context, 10)
+ statusInfo.setPaddingRelative(Utils.dpToPx(context, 28), 0, 0, 0)
+ statusInfo.show()
+ }
+
+ private fun setReblogsCount(reblogsCount: Int) = with(binding) {
+ statusReblogsCount.text = formatNumber(reblogsCount.toLong(), 1000)
+ }
+
+ private fun setFavouritedCount(favouritedCount: Int) = with(binding) {
+ statusFavouritesCount.text = formatNumber(favouritedCount.toLong(), 1000)
+ }
+
+ protected fun hideStatusInfo() = with(binding) {
+ statusInfo.hide()
+ }
+
+ private fun setupCollapsedState(
+ sensitive: Boolean,
+ expanded: Boolean,
+ status: StatusViewData,
+ listener: StatusActionListener,
+ ) = with(binding) {
+ /* input filter for TextViews have to be set before text */
+ if (status.isCollapsible && (!sensitive || expanded)) {
+ buttonToggleContent.setOnClickListener {
+ val position = bindingAdapterPosition.takeIf { it != RecyclerView.NO_POSITION } ?: return@setOnClickListener
+ listener.onContentCollapsedChange(
+ !status.isCollapsed,
+ position,
+ )
+ }
+ buttonToggleContent.show()
+ if (status.isCollapsed) {
+ buttonToggleContent.setText(R.string.post_content_warning_show_more)
+ content.filters = COLLAPSE_INPUT_FILTER
+ } else {
+ buttonToggleContent.setText(R.string.post_content_warning_show_less)
+ content.filters = NO_INPUT_FILTER
+ }
+ } else {
+ buttonToggleContent.hide()
+ content.filters = NO_INPUT_FILTER
+ }
+ }
+
+ override fun showStatusContent(show: Boolean) = with(binding) {
+ super.showStatusContent(show)
+ buttonToggleContent.visibility = if (show) View.VISIBLE else View.GONE
+ }
+
+ override fun toggleExpandedState(
+ sensitive: Boolean,
+ expanded: Boolean,
+ status: StatusViewData,
+ statusDisplayOptions: StatusDisplayOptions,
+ listener: StatusActionListener,
+ ) {
+ setupCollapsedState(sensitive, expanded, status, listener)
+ super.toggleExpandedState(sensitive, expanded, status, statusDisplayOptions, listener)
+ }
+
+ companion object {
+ private val COLLAPSE_INPUT_FILTER = arrayOf(SmartLengthInputFilter)
+ private val NO_INPUT_FILTER = arrayOfNulls(0)
+ }
+}
diff --git a/app/src/main/java/app/pachli/components/conversation/ConversationViewHolder.java b/app/src/main/java/app/pachli/components/conversation/ConversationViewHolder.java
deleted file mode 100644
index 85eb48e8c..000000000
--- a/app/src/main/java/app/pachli/components/conversation/ConversationViewHolder.java
+++ /dev/null
@@ -1,178 +0,0 @@
-/* Copyright 2017 Andrew Dawson
- *
- * This file is a part of Pachli.
- *
- * 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.
- *
- * Pachli 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 app.pachli.components.conversation;
-
-import android.content.Context;
-import android.text.InputFilter;
-import android.text.TextUtils;
-import android.view.View;
-import android.widget.Button;
-import android.widget.ImageView;
-import android.widget.TextView;
-
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
-import androidx.recyclerview.widget.RecyclerView;
-
-import java.util.List;
-
-import app.pachli.R;
-import app.pachli.adapter.StatusBaseViewHolder;
-import app.pachli.entity.Attachment;
-import app.pachli.entity.Status;
-import app.pachli.entity.TimelineAccount;
-import app.pachli.interfaces.StatusActionListener;
-import app.pachli.util.ImageLoadingHelper;
-import app.pachli.util.SmartLengthInputFilter;
-import app.pachli.util.StatusDisplayOptions;
-import app.pachli.viewdata.StatusViewData;
-
-public class ConversationViewHolder extends StatusBaseViewHolder {
- private static final InputFilter[] COLLAPSE_INPUT_FILTER = new InputFilter[]{SmartLengthInputFilter.INSTANCE};
- private static final InputFilter[] NO_INPUT_FILTER = new InputFilter[0];
-
- private final TextView conversationNameTextView;
- private final Button contentCollapseButton;
- @NonNull
- private final ImageView[] avatars;
-
- private final StatusDisplayOptions statusDisplayOptions;
- private final StatusActionListener listener;
-
- ConversationViewHolder(@NonNull View itemView,
- StatusDisplayOptions statusDisplayOptions,
- StatusActionListener listener) {
- super(itemView);
- conversationNameTextView = itemView.findViewById(R.id.conversation_name);
- contentCollapseButton = itemView.findViewById(R.id.button_toggle_content);
- avatars = new ImageView[]{
- avatar,
- itemView.findViewById(R.id.status_avatar_1),
- itemView.findViewById(R.id.status_avatar_2)
- };
- this.statusDisplayOptions = statusDisplayOptions;
-
- this.listener = listener;
- }
-
- void setupWithConversation(
- @NonNull ConversationViewData conversation,
- @Nullable Object payloads
- ) {
-
- StatusViewData statusViewData = conversation.getLastStatus();
- Status status = statusViewData.getStatus();
-
- if (payloads == null) {
- TimelineAccount account = status.getAccount();
-
- setupCollapsedState(statusViewData.isCollapsible(), statusViewData.isCollapsed(), statusViewData.isExpanded(), statusViewData.getSpoilerText(), listener);
-
- setDisplayName(account.getName(), account.getEmojis(), statusDisplayOptions);
- setUsername(account.getUsername());
- setMetaData(statusViewData, statusDisplayOptions, listener);
- setIsReply(status.getInReplyToId() != null);
- setFavourited(status.getFavourited());
- setBookmarked(status.getBookmarked());
- List attachments = status.getAttachments();
- boolean sensitive = status.getSensitive();
- if (statusDisplayOptions.mediaPreviewEnabled() && hasPreviewableAttachment(attachments)) {
- setMediaPreviews(attachments, sensitive, listener, statusViewData.isShowingContent(),
- statusDisplayOptions.useBlurhash());
-
- if (attachments.size() == 0) {
- hideSensitiveMediaWarning();
- }
- // Hide the unused label.
- for (TextView mediaLabel : mediaLabels) {
- mediaLabel.setVisibility(View.GONE);
- }
- } else {
- setMediaLabel(attachments, sensitive, listener, statusViewData.isShowingContent());
- // Hide all unused views.
- mediaPreview.setVisibility(View.GONE);
- hideSensitiveMediaWarning();
- }
-
- setupButtons(listener, account.getId(), statusViewData.getContent().toString(),
- statusDisplayOptions);
-
- setSpoilerAndContent(statusViewData, statusDisplayOptions, listener);
-
- setConversationName(conversation.getAccounts());
-
- setAvatars(conversation.getAccounts());
- } else {
- if (payloads instanceof List) {
- for (Object item : (List>) payloads) {
- if (Key.KEY_CREATED.equals(item)) {
- setMetaData(statusViewData, statusDisplayOptions, listener);
- }
- }
- }
- }
- }
-
- private void setConversationName(@NonNull List accounts) {
- Context context = conversationNameTextView.getContext();
- String conversationName = "";
- if (accounts.size() == 1) {
- conversationName = context.getString(R.string.conversation_1_recipients, accounts.get(0).getUsername());
- } else if (accounts.size() == 2) {
- conversationName = context.getString(R.string.conversation_2_recipients, accounts.get(0).getUsername(), accounts.get(1).getUsername());
- } else if (accounts.size() > 2) {
- conversationName = context.getString(R.string.conversation_more_recipients, accounts.get(0).getUsername(), accounts.get(1).getUsername(), accounts.size() - 2);
- }
-
- conversationNameTextView.setText(conversationName);
- }
-
- private void setAvatars(@NonNull List accounts) {
- for (int i = 0; i < avatars.length; i++) {
- ImageView avatarView = avatars[i];
- if (i < accounts.size()) {
- ImageLoadingHelper.loadAvatar(accounts.get(i).getAvatar(), avatarView,
- avatarRadius48dp, statusDisplayOptions.animateAvatars(), null);
- avatarView.setVisibility(View.VISIBLE);
- } else {
- avatarView.setVisibility(View.GONE);
- }
- }
- }
-
- private void setupCollapsedState(boolean collapsible, boolean collapsed, boolean expanded, String spoilerText, @NonNull final StatusActionListener listener) {
- /* input filter for TextViews have to be set before text */
- if (collapsible && (expanded || TextUtils.isEmpty(spoilerText))) {
- contentCollapseButton.setOnClickListener(view -> {
- int position = getBindingAdapterPosition();
- if (position != RecyclerView.NO_POSITION)
- listener.onContentCollapsedChange(!collapsed, position);
- });
-
- contentCollapseButton.setVisibility(View.VISIBLE);
- if (collapsed) {
- contentCollapseButton.setText(R.string.post_content_warning_show_more);
- content.setFilters(COLLAPSE_INPUT_FILTER);
- } else {
- contentCollapseButton.setText(R.string.post_content_warning_show_less);
- content.setFilters(NO_INPUT_FILTER);
- }
- } else {
- contentCollapseButton.setVisibility(View.GONE);
- content.setFilters(NO_INPUT_FILTER);
- }
- }
-}
diff --git a/app/src/main/java/app/pachli/components/conversation/ConversationViewHolder.kt b/app/src/main/java/app/pachli/components/conversation/ConversationViewHolder.kt
new file mode 100644
index 000000000..e59fff6dc
--- /dev/null
+++ b/app/src/main/java/app/pachli/components/conversation/ConversationViewHolder.kt
@@ -0,0 +1,179 @@
+/* Copyright 2017 Andrew Dawson
+ *
+ * This file is a part of Pachli.
+ *
+ * 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.
+ *
+ * Pachli 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 app.pachli.components.conversation
+
+import android.text.InputFilter
+import android.text.TextUtils
+import android.view.View
+import android.widget.Button
+import android.widget.ImageView
+import android.widget.TextView
+import androidx.recyclerview.widget.RecyclerView
+import app.pachli.R
+import app.pachli.adapter.StatusBaseViewHolder
+import app.pachli.interfaces.StatusActionListener
+import app.pachli.util.SmartLengthInputFilter
+import app.pachli.util.StatusDisplayOptions
+import app.pachli.util.hide
+import app.pachli.util.loadAvatar
+import app.pachli.util.show
+
+class ConversationViewHolder internal constructor(
+ itemView: View,
+ private val statusDisplayOptions: StatusDisplayOptions,
+ private val listener: StatusActionListener,
+) : StatusBaseViewHolder(itemView) {
+ private val conversationNameTextView: TextView
+ private val contentCollapseButton: Button
+ private val avatars: Array
+
+ init {
+ conversationNameTextView = itemView.findViewById(R.id.conversation_name)
+ contentCollapseButton = itemView.findViewById(R.id.button_toggle_content)
+ avatars = arrayOf(
+ avatar,
+ itemView.findViewById(R.id.status_avatar_1),
+ itemView.findViewById(R.id.status_avatar_2),
+ )
+ }
+
+ fun setupWithConversation(
+ conversation: ConversationViewData,
+ payloads: Any?,
+ ) {
+ val statusViewData = conversation.lastStatus
+ val (_, _, account, inReplyToId, _, _, _, _, _, _, _, _, _, _, favourited, bookmarked, sensitive, _, _, attachments) = statusViewData.status
+ if (payloads == null) {
+ setupCollapsedState(
+ statusViewData.isCollapsible,
+ statusViewData.isCollapsed,
+ statusViewData.isExpanded,
+ statusViewData.spoilerText,
+ listener,
+ )
+ setDisplayName(account.name, account.emojis, statusDisplayOptions)
+ setUsername(account.username)
+ setMetaData(statusViewData, statusDisplayOptions, listener)
+ setIsReply(inReplyToId != null)
+ setFavourited(favourited)
+ setBookmarked(bookmarked)
+ if (statusDisplayOptions.mediaPreviewEnabled && hasPreviewableAttachment(attachments)) {
+ setMediaPreviews(
+ attachments,
+ sensitive,
+ listener,
+ statusViewData.isShowingContent,
+ statusDisplayOptions.useBlurhash,
+ )
+ if (attachments.isEmpty()) {
+ hideSensitiveMediaWarning()
+ }
+ // Hide the unused label.
+ for (mediaLabel in mediaLabels) {
+ mediaLabel.visibility = View.GONE
+ }
+ } else {
+ setMediaLabel(attachments, sensitive, listener, statusViewData.isShowingContent)
+ // Hide all unused views.
+ mediaPreview.visibility = View.GONE
+ hideSensitiveMediaWarning()
+ }
+ setupButtons(
+ listener,
+ account.id,
+ statusDisplayOptions,
+ )
+ setSpoilerAndContent(statusViewData, statusDisplayOptions, listener)
+ setConversationName(conversation.accounts)
+ setAvatars(conversation.accounts)
+ } else {
+ if (payloads is List<*>) {
+ for (item in payloads) {
+ if (Key.KEY_CREATED == item) {
+ setMetaData(statusViewData, statusDisplayOptions, listener)
+ }
+ }
+ }
+ }
+ }
+
+ private fun setConversationName(accounts: List) {
+ conversationNameTextView.text = when (accounts.size) {
+ 1 -> context.getString(
+ R.string.conversation_1_recipients,
+ accounts[0].username,
+ )
+ 2 -> context.getString(
+ R.string.conversation_2_recipients,
+ accounts[0].username,
+ accounts[1].username,
+ )
+ else -> context.getString(
+ R.string.conversation_more_recipients,
+ accounts[0].username,
+ accounts[1].username,
+ accounts.size - 2,
+ )
+ }
+ }
+
+ private fun setAvatars(accounts: List) {
+ avatars.withIndex().forEach { views ->
+ accounts.getOrNull(views.index)?.also { account ->
+ loadAvatar(
+ account.avatar,
+ views.value,
+ avatarRadius48dp,
+ statusDisplayOptions.animateAvatars,
+ )
+ views.value.show()
+ } ?: views.value.hide()
+ }
+ }
+
+ private fun setupCollapsedState(
+ collapsible: Boolean,
+ collapsed: Boolean,
+ expanded: Boolean,
+ spoilerText: String,
+ listener: StatusActionListener,
+ ) {
+ /* input filter for TextViews have to be set before text */
+ if (collapsible && (expanded || TextUtils.isEmpty(spoilerText))) {
+ contentCollapseButton.setOnClickListener {
+ val position = bindingAdapterPosition.takeIf { it != RecyclerView.NO_POSITION } ?: return@setOnClickListener
+ listener.onContentCollapsedChange(!collapsed, position)
+ }
+ contentCollapseButton.show()
+ if (collapsed) {
+ contentCollapseButton.setText(R.string.post_content_warning_show_more)
+ content.filters = COLLAPSE_INPUT_FILTER
+ } else {
+ contentCollapseButton.setText(R.string.post_content_warning_show_less)
+ content.filters = NO_INPUT_FILTER
+ }
+ } else {
+ contentCollapseButton.visibility = View.GONE
+ content.filters = NO_INPUT_FILTER
+ }
+ }
+
+ companion object {
+ private val COLLAPSE_INPUT_FILTER = arrayOf(SmartLengthInputFilter)
+ private val NO_INPUT_FILTER = arrayOfNulls(0)
+ }
+}
diff --git a/app/src/main/java/app/pachli/components/notifications/NotificationsPagingAdapter.kt b/app/src/main/java/app/pachli/components/notifications/NotificationsPagingAdapter.kt
index 2a2ebce53..a6465a743 100644
--- a/app/src/main/java/app/pachli/components/notifications/NotificationsPagingAdapter.kt
+++ b/app/src/main/java/app/pachli/components/notifications/NotificationsPagingAdapter.kt
@@ -145,7 +145,7 @@ class NotificationsPagingAdapter(
)
}
NotificationViewKind.STATUS_FILTERED -> {
- StatusViewHolder(
+ FilterableStatusViewHolder(
ItemStatusWrapperBinding.inflate(inflater, parent, false),
statusActionListener,
accountId,
diff --git a/app/src/main/java/app/pachli/components/notifications/StatusViewHolder.kt b/app/src/main/java/app/pachli/components/notifications/StatusViewHolder.kt
index f0b9b91e9..61e7ab703 100644
--- a/app/src/main/java/app/pachli/components/notifications/StatusViewHolder.kt
+++ b/app/src/main/java/app/pachli/components/notifications/StatusViewHolder.kt
@@ -17,18 +17,20 @@
package app.pachli.components.notifications
-import androidx.viewbinding.ViewBinding
+import app.pachli.adapter.FilterableStatusViewHolder
import app.pachli.adapter.StatusViewHolder
+import app.pachli.databinding.ItemStatusBinding
+import app.pachli.databinding.ItemStatusWrapperBinding
import app.pachli.entity.Notification
import app.pachli.interfaces.StatusActionListener
import app.pachli.util.StatusDisplayOptions
import app.pachli.viewdata.NotificationViewData
internal class StatusViewHolder(
- binding: ViewBinding,
+ binding: ItemStatusBinding,
private val statusActionListener: StatusActionListener,
private val accountId: String,
-) : NotificationsPagingAdapter.ViewHolder, StatusViewHolder(binding.root) {
+) : NotificationsPagingAdapter.ViewHolder, StatusViewHolder(binding) {
override fun bind(
viewData: NotificationViewData,
@@ -58,3 +60,38 @@ internal class StatusViewHolder(
}
}
}
+
+class FilterableStatusViewHolder(
+ binding: ItemStatusWrapperBinding,
+ private val statusActionListener: StatusActionListener,
+ private val accountId: String,
+) : NotificationsPagingAdapter.ViewHolder, FilterableStatusViewHolder(binding) {
+ // Note: Identical to bind() in StatusViewHolder above
+ override fun bind(
+ viewData: NotificationViewData,
+ payloads: List<*>?,
+ statusDisplayOptions: StatusDisplayOptions,
+ ) {
+ val statusViewData = viewData.statusViewData
+ if (statusViewData == null) {
+ // Hide null statuses. Shouldn't happen according to the spec, but some servers
+ // have been seen to do this (https://github.com/tuskyapp/Tusky/issues/2252)
+ showStatusContent(false)
+ } else {
+ if (payloads.isNullOrEmpty()) {
+ showStatusContent(true)
+ }
+ setupWithStatus(
+ statusViewData,
+ statusActionListener,
+ statusDisplayOptions,
+ payloads?.firstOrNull(),
+ )
+ }
+ if (viewData.type == Notification.Type.POLL) {
+ setPollInfo(accountId == viewData.account.id)
+ } else {
+ hideStatusInfo()
+ }
+ }
+}
diff --git a/app/src/main/java/app/pachli/components/report/adapter/StatusViewHolder.kt b/app/src/main/java/app/pachli/components/report/adapter/StatusViewHolder.kt
index 8fa2fa5ee..15aa873a4 100644
--- a/app/src/main/java/app/pachli/components/report/adapter/StatusViewHolder.kt
+++ b/app/src/main/java/app/pachli/components/report/adapter/StatusViewHolder.kt
@@ -42,7 +42,7 @@ import app.pachli.viewdata.PollViewData
import app.pachli.viewdata.StatusViewData
import java.util.Date
-class StatusViewHolder(
+open class StatusViewHolder(
private val binding: ItemReportStatusBinding,
private val statusDisplayOptions: StatusDisplayOptions,
private val viewState: StatusViewState,
diff --git a/app/src/main/java/app/pachli/components/search/adapter/SearchStatusesAdapter.kt b/app/src/main/java/app/pachli/components/search/adapter/SearchStatusesAdapter.kt
index 8a33b9f17..1713e59c1 100644
--- a/app/src/main/java/app/pachli/components/search/adapter/SearchStatusesAdapter.kt
+++ b/app/src/main/java/app/pachli/components/search/adapter/SearchStatusesAdapter.kt
@@ -19,8 +19,8 @@ import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.paging.PagingDataAdapter
import androidx.recyclerview.widget.DiffUtil
-import app.pachli.R
import app.pachli.adapter.StatusViewHolder
+import app.pachli.databinding.ItemStatusBinding
import app.pachli.interfaces.StatusActionListener
import app.pachli.util.StatusDisplayOptions
import app.pachli.viewdata.StatusViewData
@@ -31,9 +31,9 @@ class SearchStatusesAdapter(
) : PagingDataAdapter(STATUS_COMPARATOR) {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): StatusViewHolder {
- val view = LayoutInflater.from(parent.context)
- .inflate(R.layout.item_status, parent, false)
- return StatusViewHolder(view)
+ return StatusViewHolder(
+ ItemStatusBinding.inflate(LayoutInflater.from(parent.context), parent, false),
+ )
}
override fun onBindViewHolder(holder: StatusViewHolder, position: Int) {
diff --git a/app/src/main/java/app/pachli/components/timeline/TimelinePagingAdapter.kt b/app/src/main/java/app/pachli/components/timeline/TimelinePagingAdapter.kt
index fc9b39946..dbe94fba0 100644
--- a/app/src/main/java/app/pachli/components/timeline/TimelinePagingAdapter.kt
+++ b/app/src/main/java/app/pachli/components/timeline/TimelinePagingAdapter.kt
@@ -21,8 +21,11 @@ import androidx.paging.PagingDataAdapter
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.RecyclerView
import app.pachli.R
+import app.pachli.adapter.FilterableStatusViewHolder
import app.pachli.adapter.StatusBaseViewHolder
import app.pachli.adapter.StatusViewHolder
+import app.pachli.databinding.ItemStatusBinding
+import app.pachli.databinding.ItemStatusWrapperBinding
import app.pachli.entity.Filter
import app.pachli.interfaces.StatusActionListener
import app.pachli.util.StatusDisplayOptions
@@ -36,10 +39,10 @@ class TimelinePagingAdapter(
val inflater = LayoutInflater.from(viewGroup.context)
return when (viewType) {
VIEW_TYPE_STATUS_FILTERED -> {
- StatusViewHolder(inflater.inflate(R.layout.item_status_wrapper, viewGroup, false))
+ FilterableStatusViewHolder(ItemStatusWrapperBinding.inflate(inflater, viewGroup, false))
}
VIEW_TYPE_STATUS -> {
- StatusViewHolder(inflater.inflate(R.layout.item_status, viewGroup, false))
+ StatusViewHolder(ItemStatusBinding.inflate(inflater, viewGroup, false))
}
else -> return object : RecyclerView.ViewHolder(inflater.inflate(R.layout.item_placeholder, viewGroup, false)) {}
}
diff --git a/app/src/main/java/app/pachli/components/viewthread/ThreadAdapter.kt b/app/src/main/java/app/pachli/components/viewthread/ThreadAdapter.kt
index 416387de2..d246d7435 100644
--- a/app/src/main/java/app/pachli/components/viewthread/ThreadAdapter.kt
+++ b/app/src/main/java/app/pachli/components/viewthread/ThreadAdapter.kt
@@ -19,10 +19,13 @@ import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.ListAdapter
-import app.pachli.R
+import app.pachli.adapter.FilterableStatusViewHolder
import app.pachli.adapter.StatusBaseViewHolder
import app.pachli.adapter.StatusDetailedViewHolder
import app.pachli.adapter.StatusViewHolder
+import app.pachli.databinding.ItemStatusBinding
+import app.pachli.databinding.ItemStatusDetailedBinding
+import app.pachli.databinding.ItemStatusWrapperBinding
import app.pachli.entity.Filter
import app.pachli.interfaces.StatusActionListener
import app.pachli.util.StatusDisplayOptions
@@ -37,13 +40,13 @@ class ThreadAdapter(
val inflater = LayoutInflater.from(parent.context)
return when (viewType) {
VIEW_TYPE_STATUS -> {
- StatusViewHolder(inflater.inflate(R.layout.item_status, parent, false))
+ StatusViewHolder(ItemStatusBinding.inflate(inflater, parent, false))
}
VIEW_TYPE_STATUS_FILTERED -> {
- StatusViewHolder(inflater.inflate(R.layout.item_status_wrapper, parent, false))
+ FilterableStatusViewHolder(ItemStatusWrapperBinding.inflate(inflater, parent, false))
}
VIEW_TYPE_STATUS_DETAILED -> {
- StatusDetailedViewHolder(inflater.inflate(R.layout.item_status_detailed, parent, false))
+ StatusDetailedViewHolder(ItemStatusDetailedBinding.inflate(inflater, parent, false))
}
else -> error("Unknown item type: $viewType")
}
diff --git a/app/src/main/java/app/pachli/entity/Attachment.kt b/app/src/main/java/app/pachli/entity/Attachment.kt
index a45256633..be8fdf5d5 100644
--- a/app/src/main/java/app/pachli/entity/Attachment.kt
+++ b/app/src/main/java/app/pachli/entity/Attachment.kt
@@ -16,6 +16,8 @@
package app.pachli.entity
import android.os.Parcelable
+import androidx.annotation.DrawableRes
+import app.pachli.R
import com.google.gson.JsonDeserializationContext
import com.google.gson.JsonDeserializer
import com.google.gson.JsonElement
@@ -53,6 +55,15 @@ data class Attachment(
UNKNOWN,
}
+ /** @return a drawable resource for an icon to indicate the attachment type */
+ @DrawableRes
+ fun iconResource() = when (this.type) {
+ Type.IMAGE -> R.drawable.ic_photo_24dp
+ Type.GIFV, Type.VIDEO -> R.drawable.ic_videocam_24dp
+ Type.AUDIO -> R.drawable.ic_music_box_24dp
+ Type.UNKNOWN -> R.drawable.ic_attach_file_24dp
+ }
+
class MediaTypeDeserializer : JsonDeserializer {
@Throws(JsonParseException::class)
override fun deserialize(json: JsonElement, classOfT: java.lang.reflect.Type, context: JsonDeserializationContext): Type {
diff --git a/app/src/main/java/app/pachli/entity/Status.kt b/app/src/main/java/app/pachli/entity/Status.kt
index 88cb6351f..078f311e3 100644
--- a/app/src/main/java/app/pachli/entity/Status.kt
+++ b/app/src/main/java/app/pachli/entity/Status.kt
@@ -15,8 +15,13 @@
package app.pachli.entity
+import android.content.Context
+import android.graphics.drawable.Drawable
import android.text.SpannableStringBuilder
import android.text.style.URLSpan
+import android.widget.TextView
+import androidx.appcompat.content.res.AppCompatResources
+import app.pachli.R
import app.pachli.util.parseAsMastodonHtml
import com.google.gson.annotations.SerializedName
import java.util.Date
@@ -59,13 +64,6 @@ data class Status(
val actionableStatus: Status
get() = reblog ?: this
- /** Helpers for Java */
- fun copyWithFavourited(favourited: Boolean): Status = copy(favourited = favourited)
- fun copyWithReblogged(reblogged: Boolean): Status = copy(reblogged = reblogged)
- fun copyWithBookmarked(bookmarked: Boolean): Status = copy(bookmarked = bookmarked)
- fun copyWithPoll(poll: Poll?): Status = copy(poll = poll)
- fun copyWithPinned(pinned: Boolean): Status = copy(pinned = pinned)
-
enum class Visibility(val num: Int) {
UNKNOWN(0),
@@ -178,3 +176,43 @@ data class Status(
const val MAX_POLL_OPTIONS = 4
}
}
+
+/**
+ * @return A description for this visibility, or "" if it's null or [Status.Visibility.UNKNOWN].
+ */
+fun Status.Visibility?.description(context: Context): CharSequence {
+ this ?: return ""
+
+ val resource: Int = when (this) {
+ Status.Visibility.PUBLIC -> R.string.description_visibility_public
+ Status.Visibility.UNLISTED -> R.string.description_visibility_unlisted
+ Status.Visibility.PRIVATE -> R.string.description_visibility_private
+ Status.Visibility.DIRECT -> R.string.description_visibility_direct
+ Status.Visibility.UNKNOWN -> return ""
+ }
+ return context.getString(resource)
+}
+
+/**
+ * @return An icon for this visibility scaled and coloured to match the text on [textView].
+ * Returns null if visibility is [Status.Visibility.UNKNOWN].
+ */
+fun Status.Visibility?.icon(textView: TextView): Drawable? {
+ this ?: return null
+
+ val resource: Int = when (this) {
+ Status.Visibility.PUBLIC -> R.drawable.ic_public_24dp
+ Status.Visibility.UNLISTED -> R.drawable.ic_lock_open_24dp
+ Status.Visibility.PRIVATE -> R.drawable.ic_lock_outline_24dp
+ Status.Visibility.DIRECT -> R.drawable.ic_email_24dp
+ Status.Visibility.UNKNOWN -> return null
+ }
+ val visibilityDrawable = AppCompatResources.getDrawable(
+ textView.context,
+ resource,
+ ) ?: return null
+ val size = textView.textSize.toInt()
+ visibilityDrawable.setBounds(0, 0, size, size)
+ visibilityDrawable.setTint(textView.currentTextColor)
+ return visibilityDrawable
+}
diff --git a/app/src/main/java/app/pachli/view/PollView.kt b/app/src/main/java/app/pachli/view/PollView.kt
index 1103e0d5d..5affc72af 100644
--- a/app/src/main/java/app/pachli/view/PollView.kt
+++ b/app/src/main/java/app/pachli/view/PollView.kt
@@ -62,7 +62,7 @@ class PollView @JvmOverloads constructor(
* should be treated as a navigation click. If non-null the user has voted,
* and [choices] contains the option(s) they voted for.
*/
- fun onClick(choices: List?): Unit
+ fun onClick(choices: List?)
}
val binding: StatusPollBinding
diff --git a/app/src/main/res/layout/item_status.xml b/app/src/main/res/layout/item_status.xml
index 9e08e7b29..d11ecca4d 100644
--- a/app/src/main/res/layout/item_status.xml
+++ b/app/src/main/res/layout/item_status.xml
@@ -259,7 +259,7 @@
sparkbutton:secondaryColor="?colorPrimaryContainer" />
-
+
-
\ No newline at end of file
+ android:visibility="gone" />
+