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" /> +