From b12a3ecfe729b94c7067aef1dfb3404b07663ad9 Mon Sep 17 00:00:00 2001 From: David Edwards Date: Sat, 7 Jan 2023 23:01:27 +0100 Subject: [PATCH 01/40] Add initial feature for viewing trending graphs. Currently only views hash tag trends. Contains API additions, tab additions and a set of trending components. --- app/build.gradle | 4 +- .../java/com/keylesspalace/tusky/TabData.kt | 8 + .../tusky/TabPreferenceActivity.kt | 4 + .../tusky/adapter/TagViewHolder.kt | 62 + .../tusky/adapter/TrendingBaseViewHolder.java | 1179 +++++++++++++++++ .../components/trending/TrendingFragment.kt | 289 ++++ .../trending/TrendingPagingAdapter.kt | 140 ++ .../trending/viewmodel/TrendingViewModel.kt | 62 + .../tusky/di/FragmentBuildersModule.kt | 4 + .../tusky/di/ViewModelFactory.kt | 6 + .../tusky/entity/TrendingTagsResult.kt | 29 + .../tusky/network/MastodonApi.kt | 4 + .../tusky/usecase/TrendingCases.kt | 44 + .../keylesspalace/tusky/util/ViewDataUtils.kt | 9 + .../com/keylesspalace/tusky/view/GraphView.kt | 206 +++ .../tusky/viewdata/TrendingViewData.kt | 37 + .../main/res/drawable/ic_trending_up_24px.xml | 11 + app/src/main/res/layout/fragment_trending.xml | 54 + app/src/main/res/layout/item_trending.xml | 64 + .../res/layout/item_trending_placeholder.xml | 23 + app/src/main/res/values/attrs.xml | 8 + app/src/main/res/values/dimens.xml | 2 + app/src/main/res/values/strings.xml | 1 + 23 files changed, 2249 insertions(+), 1 deletion(-) create mode 100644 app/src/main/java/com/keylesspalace/tusky/adapter/TagViewHolder.kt create mode 100644 app/src/main/java/com/keylesspalace/tusky/adapter/TrendingBaseViewHolder.java create mode 100644 app/src/main/java/com/keylesspalace/tusky/components/trending/TrendingFragment.kt create mode 100644 app/src/main/java/com/keylesspalace/tusky/components/trending/TrendingPagingAdapter.kt create mode 100644 app/src/main/java/com/keylesspalace/tusky/components/trending/viewmodel/TrendingViewModel.kt create mode 100644 app/src/main/java/com/keylesspalace/tusky/entity/TrendingTagsResult.kt create mode 100644 app/src/main/java/com/keylesspalace/tusky/usecase/TrendingCases.kt create mode 100644 app/src/main/java/com/keylesspalace/tusky/view/GraphView.kt create mode 100644 app/src/main/java/com/keylesspalace/tusky/viewdata/TrendingViewData.kt create mode 100644 app/src/main/res/drawable/ic_trending_up_24px.xml create mode 100644 app/src/main/res/layout/fragment_trending.xml create mode 100644 app/src/main/res/layout/item_trending.xml create mode 100644 app/src/main/res/layout/item_trending_placeholder.xml diff --git a/app/build.gradle b/app/build.gradle index d93dd2ccba..b5b11cd1eb 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -48,7 +48,9 @@ android { shrinkResources true proguardFiles 'proguard-rules.pro' } - debug {} + debug { + applicationIdSuffix '.debug' + } } flavorDimensions "color" diff --git a/app/src/main/java/com/keylesspalace/tusky/TabData.kt b/app/src/main/java/com/keylesspalace/tusky/TabData.kt index 0db852114a..23dac00e2c 100644 --- a/app/src/main/java/com/keylesspalace/tusky/TabData.kt +++ b/app/src/main/java/com/keylesspalace/tusky/TabData.kt @@ -22,6 +22,7 @@ import androidx.fragment.app.Fragment import com.keylesspalace.tusky.components.conversation.ConversationsFragment import com.keylesspalace.tusky.components.timeline.TimelineFragment import com.keylesspalace.tusky.components.timeline.viewmodel.TimelineViewModel +import com.keylesspalace.tusky.components.trending.TrendingFragment import com.keylesspalace.tusky.fragment.NotificationsFragment /** this would be a good case for a sealed class, but that does not work nice with Room */ @@ -31,6 +32,7 @@ const val NOTIFICATIONS = "Notifications" const val LOCAL = "Local" const val FEDERATED = "Federated" const val DIRECT = "Direct" +const val TRENDING = "Trending" const val HASHTAG = "Hashtag" const val LIST = "List" @@ -75,6 +77,12 @@ fun createTabDataFromId(id: String, arguments: List = emptyList()): TabD R.drawable.ic_reblog_direct_24dp, { ConversationsFragment.newInstance() } ) + TRENDING -> TabData( + TRENDING, + R.string.title_public_trending, + R.drawable.ic_trending_up_24px, + { TrendingFragment.newInstance() } + ) HASHTAG -> TabData( HASHTAG, R.string.hashtags, diff --git a/app/src/main/java/com/keylesspalace/tusky/TabPreferenceActivity.kt b/app/src/main/java/com/keylesspalace/tusky/TabPreferenceActivity.kt index 0f20a7851d..d476ea7288 100644 --- a/app/src/main/java/com/keylesspalace/tusky/TabPreferenceActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/TabPreferenceActivity.kt @@ -317,6 +317,10 @@ class TabPreferenceActivity : BaseActivity(), Injectable, ItemInteractionListene if (!currentTabs.contains(directMessagesTab)) { addableTabs.add(directMessagesTab) } + val trendingTab = createTabDataFromId(TRENDING) + if (!currentTabs.contains(trendingTab)) { + addableTabs.add(trendingTab) + } addableTabs.add(createTabDataFromId(HASHTAG)) addableTabs.add(createTabDataFromId(LIST)) diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/TagViewHolder.kt b/app/src/main/java/com/keylesspalace/tusky/adapter/TagViewHolder.kt new file mode 100644 index 0000000000..f3e1adcc32 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/TagViewHolder.kt @@ -0,0 +1,62 @@ +/* Copyright 2023 Tusky Contributors + * + * This file is a part of Tusky. + * + * This program is free software; you can redistribute it and/or modify it under the terms of the + * GNU General Public License as published by the Free Software Foundation; either version 3 of the + * License, or (at your option) any later version. + * + * Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even + * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General + * Public License for more details. + * + * You should have received a copy of the GNU General Public License along with Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.adapter + +import android.view.View +import android.widget.TextView +import androidx.recyclerview.widget.RecyclerView +import com.keylesspalace.tusky.R +import com.keylesspalace.tusky.entity.TrendingTagHistory +import com.keylesspalace.tusky.view.GraphView +import com.keylesspalace.tusky.viewdata.TrendingViewData + +class TagViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { + private val graphView: GraphView + private val tagView: TextView + private val textView: TextView + + init { + graphView = itemView.findViewById(R.id.graph) + tagView = itemView.findViewById(R.id.tag) + textView = itemView.findViewById(R.id.text) + } + + fun setup( + tagViewData: TrendingViewData.Tag, + maxTrendingValue: Int, + ) { + setGraph(tagViewData.tag.history, maxTrendingValue) + setTag(tagViewData.tag.name) + + val totalAccounts = tagViewData.tag.history.sumOf { it.accounts.toIntOrNull() ?: 0 } + setTextWithAccounts(totalAccounts) + } + + private fun setGraph(history: List, maxTrendingValue: Int) { + graphView.maxTrendingValue = maxTrendingValue + graphView.data = history + .reversed() + .mapNotNull { it.accounts.toIntOrNull() } + } + + private fun setTag(tag: String) { + tagView.text = itemView.context.getString(R.string.title_tag, tag) + } + + private fun setTextWithAccounts(totalAccounts: Int) { + textView.text = itemView.context.getString(R.string.talking_about_tag, totalAccounts) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/TrendingBaseViewHolder.java b/app/src/main/java/com/keylesspalace/tusky/adapter/TrendingBaseViewHolder.java new file mode 100644 index 0000000000..a0263de9dc --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/TrendingBaseViewHolder.java @@ -0,0 +1,1179 @@ +/* Copyright 2023 Tusky Contributors + * + * This file is a part of Tusky. + * + * This program is free software; you can redistribute it and/or modify it under the terms of the + * GNU General Public License as published by the Free Software Foundation; either version 3 of the + * License, or (at your option) any later version. + * + * Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even + * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General + * Public License for more details. + * + * You should have received a copy of the GNU General Public License along with Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.adapter; + +import static com.keylesspalace.tusky.viewdata.PollViewDataKt.buildDescription; + +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.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.app.AlertDialog; +import androidx.constraintlayout.widget.ConstraintLayout; +import androidx.core.content.ContextCompat; +import androidx.core.text.HtmlCompat; +import androidx.core.view.ViewKt; +import androidx.recyclerview.widget.DefaultItemAnimator; +import androidx.recyclerview.widget.LinearLayoutManager; +import androidx.recyclerview.widget.RecyclerView; + +import com.bumptech.glide.Glide; +import com.bumptech.glide.RequestBuilder; +import com.google.android.material.button.MaterialButton; +import com.google.android.material.color.MaterialColors; +import com.google.android.material.imageview.ShapeableImageView; +import com.google.android.material.shape.CornerFamily; +import com.google.android.material.shape.ShapeAppearanceModel; +import com.keylesspalace.tusky.R; +import com.keylesspalace.tusky.ViewMediaActivity; +import com.keylesspalace.tusky.entity.Attachment; +import com.keylesspalace.tusky.entity.Attachment.Focus; +import com.keylesspalace.tusky.entity.Attachment.MetaData; +import com.keylesspalace.tusky.entity.Card; +import com.keylesspalace.tusky.entity.Emoji; +import com.keylesspalace.tusky.entity.HashTag; +import com.keylesspalace.tusky.entity.Status; +import com.keylesspalace.tusky.interfaces.StatusActionListener; +import com.keylesspalace.tusky.util.AbsoluteTimeFormatter; +import com.keylesspalace.tusky.util.AttachmentHelper; +import com.keylesspalace.tusky.util.CardViewMode; +import com.keylesspalace.tusky.util.CustomEmojiHelper; +import com.keylesspalace.tusky.util.ImageLoadingHelper; +import com.keylesspalace.tusky.util.LinkHelper; +import com.keylesspalace.tusky.util.StatusDisplayOptions; +import com.keylesspalace.tusky.util.TimestampUtils; +import com.keylesspalace.tusky.util.TouchDelegateHelper; +import com.keylesspalace.tusky.view.MediaPreviewImageView; +import com.keylesspalace.tusky.view.MediaPreviewLayout; +import com.keylesspalace.tusky.viewdata.PollOptionViewData; +import com.keylesspalace.tusky.viewdata.PollViewData; +import com.keylesspalace.tusky.viewdata.PollViewDataKt; +import com.keylesspalace.tusky.viewdata.StatusViewData; +import com.keylesspalace.tusky.viewdata.TrendingViewData; + +import java.text.NumberFormat; +import java.util.Date; +import java.util.List; + +import at.connyduck.sparkbutton.SparkButton; +import at.connyduck.sparkbutton.helpers.Utils; +import kotlin.collections.CollectionsKt; + +public abstract class TrendingBaseViewHolder extends RecyclerView.ViewHolder { + public static class Key { + public static final String KEY_CREATED = "created"; + } + + private TextView displayName; + private TextView username; + private ImageButton replyButton; + private TextView replyCountLabel; + private SparkButton reblogButton; + private SparkButton favouriteButton; + private SparkButton bookmarkButton; + private ImageButton moreButton; + private ConstraintLayout mediaContainer; + protected MediaPreviewLayout mediaPreview; + private TextView sensitiveMediaWarning; + private View sensitiveMediaShow; + protected TextView[] mediaLabels; + protected CharSequence[] mediaDescriptions; + private MaterialButton contentWarningButton; + private ImageView avatarInset; + + public ImageView avatar; + public TextView metaInfo; + public TextView content; + public TextView contentWarningDescription; + + private RecyclerView pollOptions; + private TextView pollDescription; + private Button pollButton; + + private LinearLayout cardView; + private LinearLayout cardInfo; + private ShapeableImageView cardImage; + private TextView cardTitle; + private TextView cardDescription; + private TextView cardUrl; + private PollAdapter pollAdapter; + + private final NumberFormat numberFormat = NumberFormat.getNumberInstance(); + private final AbsoluteTimeFormatter absoluteTimeFormatter = new AbsoluteTimeFormatter(); + + protected int avatarRadius48dp; + private int avatarRadius36dp; + private int avatarRadius24dp; + + private final Drawable mediaPreviewUnloaded; + + protected TrendingBaseViewHolder(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); + + pollOptions = itemView.findViewById(R.id.status_poll_options); + pollDescription = itemView.findViewById(R.id.status_poll_description); + pollButton = itemView.findViewById(R.id.status_poll_button); + + cardView = itemView.findViewById(R.id.status_card_view); + cardInfo = itemView.findViewById(R.id.card_info); + cardImage = itemView.findViewById(R.id.card_image); + cardTitle = itemView.findViewById(R.id.card_title); + cardDescription = itemView.findViewById(R.id.card_description); + cardUrl = itemView.findViewById(R.id.card_link); + + pollAdapter = new PollAdapter(); + pollOptions.setAdapter(pollAdapter); + pollOptions.setLayoutManager(new LinearLayoutManager(pollOptions.getContext())); + ((DefaultItemAnimator) pollOptions.getItemAnimator()).setSupportsChangeAnimations(false); + + 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, R.attr.colorBackgroundAccent)); + + TouchDelegateHelper.expandTouchSizeToFillRow((ViewGroup) itemView, CollectionsKt.listOfNotNull(replyButton, reblogButton, favouriteButton, bookmarkButton, moreButton)); + } + + protected void setDisplayName(String name, List customEmojis, StatusDisplayOptions statusDisplayOptions) { + CharSequence emojifiedName = CustomEmojiHelper.emojify( + name, customEmojis, displayName, statusDisplayOptions.animateEmojis() + ); + displayName.setText(emojifiedName); + } + + protected void setUsername(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(boolean expanded, + @NonNull Spanned content, + @Nullable String spoilerText, + @Nullable List mentions, + @Nullable List tags, + @NonNull List emojis, + @Nullable PollViewData poll, + @NonNull StatusDisplayOptions statusDisplayOptions, + final StatusActionListener listener) { + boolean sensitive = !TextUtils.isEmpty(spoilerText); + 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 -> { + contentWarningDescription.invalidate(); + if (getBindingAdapterPosition() != RecyclerView.NO_POSITION) { + listener.onExpandedChange(!expanded, getBindingAdapterPosition()); + } + setContentWarningButtonText(!expanded); + + this.setTextVisible(sensitive, !expanded, content, mentions, tags, emojis, poll, statusDisplayOptions, listener); + }); + this.setTextVisible(sensitive, expanded, content, mentions, tags, emojis, poll, statusDisplayOptions, listener); + } else { + contentWarningDescription.setVisibility(View.GONE); + contentWarningButton.setVisibility(View.GONE); + this.setTextVisible(sensitive, true, content, mentions, tags, emojis, poll, 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); + } + } + + private void setTextVisible(boolean sensitive, + boolean expanded, + Spanned content, + List mentions, + List tags, + List emojis, + @Nullable PollViewData poll, + StatusDisplayOptions statusDisplayOptions, + final StatusActionListener listener) { + 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, expanded); + } + if (poll != null) { + setupPoll(poll, emojis, statusDisplayOptions, listener); + } else { + hidePoll(); + } + } else { + hidePoll(); + 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 hidePoll() { + pollButton.setVisibility(View.GONE); + pollDescription.setVisibility(View.GONE); + pollOptions.setVisibility(View.GONE); + } + + private void setAvatar(String url, + @Nullable String rebloggedUrl, + boolean isBot, + 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) + // passing the drawable id directly into .load() ignores night mode https://github.com/bumptech/glide/issues/4692 + .load(ContextCompat.getDrawable(avatarInset.getContext(), 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()); + + avatarRadius = avatarRadius36dp; + } + + ImageLoadingHelper.loadAvatar(url, avatar, avatarRadius, + statusDisplayOptions.animateAvatars()); + + } + + protected void setMetaData(StatusViewData.Concrete statusViewData, StatusDisplayOptions statusDisplayOptions, 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(); + String readout = TimestampUtils.getRelativeTimeSpanString(metaInfo.getContext(), then, now); + timestampText = readout; + } + } + + if (editedAt != null) { + timestampText = metaInfo.getContext().getString(R.string.post_timestamp_with_edited_indicator, timestampText); + } + metaInfo.setText(timestampText); + } + + private CharSequence getCreatedAtDescription(Date createdAt, + 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); + } + + } + + private void setReplyCount(int repliesCount) { + // This label only exists in the non-detailed view (to match the web ui) + if (replyCountLabel != null) { + 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); + } + + private BitmapDrawable decodeBlurHash(String blurhash) { + return ImageLoadingHelper.decodeBlurHash(this.avatar.getContext(), blurhash); + } + + private void loadImage(View wrapper, + MediaPreviewImageView imageView, + @Nullable String previewUrl, + @Nullable MetaData meta, + @Nullable String blurhash) { + + Drawable placeholder = blurhash != null ? decodeBlurHash(blurhash) : mediaPreviewUnloaded; + + ViewKt.doOnLayout(wrapper, view -> { + 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); + } + } + return null; + }); + } + + protected void setMediaPreviews(final List attachments, boolean sensitive, + final StatusActionListener listener, boolean showingContent, + boolean useBlurhash) { + + mediaPreview.setVisibility(View.VISIBLE); + mediaPreview.setAspectRatios(AttachmentHelper.aspectRatios(attachments)); + + mediaPreview.forEachIndexed((i, wrapper, 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)); + } + descriptionIndicator.setVisibility(hasDescription ? View.VISIBLE : View.GONE); + + loadImage( + wrapper, + 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); + 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(Attachment.Type type) { + switch (type) { + case IMAGE: + return R.drawable.ic_photo_24dp; + case GIFV: + case VIDEO: + return R.drawable.ic_videocam_24dp; + case AUDIO: + return R.drawable.ic_music_box_24dp; + default: + return 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(List attachments, boolean sensitive, + 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(View view, StatusActionListener listener, + int index, 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(final StatusActionListener listener, + final String accountId, + final String statusContent, + 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()) { + showConfirmReblogDialog(listener, statusContent, 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()) { + showConfirmFavouriteDialog(listener, statusContent, 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 showConfirmReblogDialog(StatusActionListener listener, + String statusContent, + boolean buttonState, + int position) { + int okButtonTextId = buttonState ? R.string.action_unreblog : R.string.action_reblog; + new AlertDialog.Builder(reblogButton.getContext()) + .setMessage(statusContent) + .setPositiveButton(okButtonTextId, (__, ___) -> { + listener.onReblog(!buttonState, position); + if (!buttonState) { + // Play animation only when it's reblog, not unreblog + reblogButton.playAnimation(); + } + }) + .show(); + } + + private void showConfirmFavouriteDialog(StatusActionListener listener, + String statusContent, + boolean buttonState, + int position) { + int okButtonTextId = buttonState ? R.string.action_unfavourite : R.string.action_favourite; + new AlertDialog.Builder(favouriteButton.getContext()) + .setMessage(statusContent) + .setPositiveButton(okButtonTextId, (__, ___) -> { + listener.onFavourite(!buttonState, position); + if (!buttonState) { + // Play animation only when it's favourite, not unfavourite + favouriteButton.playAnimation(); + } + }) + .show(); + } + + public void setupWithTrending(TrendingViewData.Tag tag, final StatusActionListener listener, + StatusDisplayOptions statusDisplayOptions) { + this.setupWithTrending(tag, listener, statusDisplayOptions, null); + } + + public void setupWithTrending(@NonNull TrendingViewData.Tag tag, + @NonNull final StatusActionListener listener, + @NonNull StatusDisplayOptions statusDisplayOptions, + @Nullable Object payloads) { + if (payloads == null) { + setUsername(tag.getTag().getName()); + } +// Status actionable = tag.getActionable(); +// setDisplayName(actionable.getAccount().getName(), actionable.getAccount().getEmojis(), statusDisplayOptions); +// setUsername(tag.getUsername()); +// setMetaData(tag, statusDisplayOptions, listener); +// setIsReply(actionable.getInReplyToId() != null); +// setReplyCount(actionable.getRepliesCount()); +// setAvatar(actionable.getAccount().getAvatar(), tag.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, tag.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, tag.isShowingContent()); +// // Hide all unused views. +// mediaPreview.setVisibility(View.GONE); +// hideSensitiveMediaWarning(); +// } +// +// if (cardView != null) { +// setupCard(tag, statusDisplayOptions.cardViewMode(), statusDisplayOptions, listener); +// } +// +// setupButtons(listener, actionable.getAccount().getId(), tag.getContent().toString(), +// statusDisplayOptions); +// setRebloggingEnabled(actionable.rebloggingAllowed(), actionable.getVisibility()); +// +// setSpoilerAndContent(tag.isExpanded(), tag.getContent(), tag.getSpoilerText(), +// actionable.getMentions(), actionable.getTags(), actionable.getEmojis(), +// PollViewDataKt.toViewData(actionable.getPoll()), statusDisplayOptions, +// listener); +// +// setDescriptionForStatus(tag, 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(tag, statusDisplayOptions, listener); +// } +// } +// +// } + } + + protected static boolean hasPreviewableAttachment(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.Concrete status, + StatusDisplayOptions statusDisplayOptions) { + Context context = itemView.getContext(); + Status actionable = status.getActionable(); + + 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()), + getPollDescription(status, context, statusDisplayOptions) + ); + itemView.setContentDescription(description); + } + + private static CharSequence getReblogDescription(Context context, + @NonNull StatusViewData.Concrete status) { + Status reblog = status.getRebloggingStatus(); + if (reblog != null) { + return context + .getString(R.string.post_boosted_format, reblog.getAccount().getUsername()); + } else { + return ""; + } + } + + private static CharSequence getMediaDescription(Context context, + @NonNull StatusViewData.Concrete 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); + } + + private static CharSequence getContentWarningDescription(Context context, + @NonNull StatusViewData.Concrete status) { + if (!TextUtils.isEmpty(status.getSpoilerText())) { + return context.getString(R.string.description_post_cw, status.getSpoilerText()); + } else { + return ""; + } + } + + protected static CharSequence getVisibilityDescription(Context context, Status.Visibility visibility) { + + if (visibility == null) { + return ""; + } + + int resource; + switch (visibility) { + case PUBLIC: + resource = R.string.description_visibility_public; + break; + case UNLISTED: + resource = R.string.description_visibility_unlisted; + break; + case PRIVATE: + resource = R.string.description_visibility_private; + break; + case DIRECT: + resource = R.string.description_visibility_direct; + break; + default: + return ""; + } + return context.getString(resource); + } + + private CharSequence getPollDescription(@NonNull StatusViewData.Concrete status, + Context context, + StatusDisplayOptions statusDisplayOptions) { + PollViewData poll = PollViewDataKt.toViewData(status.getActionable().getPoll()); + if (poll == null) { + return ""; + } else { + Object[] args = new CharSequence[5]; + List options = poll.getOptions(); + for (int i = 0; i < args.length; i++) { + if (i < options.size()) { + int percent = PollViewDataKt.calculatePercent(options.get(i).getVotesCount(), poll.getVotersCount(), poll.getVotesCount()); + args[i] = buildDescription(options.get(i).getTitle(), percent, options.get(i).getVoted(), context); + } else { + args[i] = ""; + } + } + args[4] = getPollInfoText(System.currentTimeMillis(), poll, statusDisplayOptions, + context); + return context.getString(R.string.description_poll, args); + } + } + + protected CharSequence getFavsText(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 ""; + } + } + + protected CharSequence getReblogsText(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 ""; + } + } + + private void setupPoll(PollViewData poll, List emojis, + StatusDisplayOptions statusDisplayOptions, + StatusActionListener listener) { + long timestamp = System.currentTimeMillis(); + + boolean expired = poll.getExpired() || (poll.getExpiresAt() != null && timestamp > poll.getExpiresAt().getTime()); + + Context context = pollDescription.getContext(); + + pollOptions.setVisibility(View.VISIBLE); + + if (expired || poll.getVoted()) { + // no voting possible + View.OnClickListener viewThreadListener = v -> { + int position = getBindingAdapterPosition(); + if (position != RecyclerView.NO_POSITION) { + listener.onViewThread(position); + } + }; + pollAdapter.setup( + poll.getOptions(), + poll.getVotesCount(), + poll.getVotersCount(), + emojis, + PollAdapter.RESULT, + viewThreadListener, + statusDisplayOptions.animateEmojis() + ); + + pollButton.setVisibility(View.GONE); + } else { + // voting possible + pollAdapter.setup( + poll.getOptions(), + poll.getVotesCount(), + poll.getVotersCount(), + emojis, + poll.getMultiple() ? PollAdapter.MULTIPLE : PollAdapter.SINGLE, + null, + statusDisplayOptions.animateEmojis() + ); + + pollButton.setVisibility(View.VISIBLE); + + pollButton.setOnClickListener(v -> { + + int position = getBindingAdapterPosition(); + + if (position != RecyclerView.NO_POSITION) { + + List pollResult = pollAdapter.getSelected(); + + if (!pollResult.isEmpty()) { + listener.onVoteInPoll(position, pollResult); + } + } + + }); + } + + pollDescription.setVisibility(View.VISIBLE); + pollDescription.setText(getPollInfoText(timestamp, poll, statusDisplayOptions, context)); + } + + private CharSequence getPollInfoText(long timestamp, PollViewData poll, + StatusDisplayOptions statusDisplayOptions, + Context context) { + String votesText; + if (poll.getVotersCount() == null) { + String voters = numberFormat.format(poll.getVotesCount()); + votesText = context.getResources().getQuantityString(R.plurals.poll_info_votes, poll.getVotesCount(), voters); + } else { + String voters = numberFormat.format(poll.getVotersCount()); + votesText = context.getResources().getQuantityString(R.plurals.poll_info_people, poll.getVotersCount(), voters); + } + CharSequence pollDurationInfo; + if (poll.getExpired()) { + pollDurationInfo = context.getString(R.string.poll_info_closed); + } else if (poll.getExpiresAt() == null) { + return votesText; + } else { + if (statusDisplayOptions.useAbsoluteTime()) { + pollDurationInfo = context.getString(R.string.poll_info_time_absolute, absoluteTimeFormatter.format(poll.getExpiresAt(), false)); + } else { + pollDurationInfo = TimestampUtils.formatPollDuration(pollDescription.getContext(), poll.getExpiresAt().getTime(), timestamp); + } + } + + return pollDescription.getContext().getString(R.string.poll_info_format, votesText, pollDurationInfo); + } + + protected void setupCard( + StatusViewData.Concrete status, + CardViewMode cardViewMode, + StatusDisplayOptions statusDisplayOptions, + final StatusActionListener listener + ) { + 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() || status.isExpanded()) && + (!status.isCollapsible() || !status.isCollapsed())) { + cardView.setVisibility(View.VISIBLE); + cardTitle.setText(card.getTitle()); + if (TextUtils.isEmpty(card.getDescription()) && TextUtils.isEmpty(card.getAuthorName())) { + cardDescription.setVisibility(View.GONE); + } else { + cardDescription.setVisibility(View.VISIBLE); + if (TextUtils.isEmpty(card.getDescription())) { + cardDescription.setText(card.getAuthorName()); + } else { + cardDescription.setText(card.getDescription()); + } + } + + cardUrl.setText(card.getUrl()); + + // Statuses from other activitypub sources can be marked sensitive even if there's no media, + // so let's blur the preview in that case + // If media previews are disabled, show placeholder for cards as well + if (statusDisplayOptions.mediaPreviewEnabled() && !actionable.getSensitive() && !TextUtils.isEmpty(card.getImage())) { + + int radius = cardImage.getContext().getResources() + .getDimensionPixelSize(R.dimen.card_radius); + ShapeAppearanceModel.Builder cardImageShape = ShapeAppearanceModel.builder(); + + if (card.getWidth() > card.getHeight()) { + cardView.setOrientation(LinearLayout.VERTICAL); + + cardImage.getLayoutParams().height = cardImage.getContext().getResources() + .getDimensionPixelSize(R.dimen.card_image_vertical_height); + cardImage.getLayoutParams().width = ViewGroup.LayoutParams.MATCH_PARENT; + cardInfo.getLayoutParams().height = ViewGroup.LayoutParams.MATCH_PARENT; + cardInfo.getLayoutParams().width = ViewGroup.LayoutParams.WRAP_CONTENT; + cardImageShape.setTopLeftCorner(CornerFamily.ROUNDED, radius); + cardImageShape.setTopRightCorner(CornerFamily.ROUNDED, radius); + } else { + cardView.setOrientation(LinearLayout.HORIZONTAL); + cardImage.getLayoutParams().height = ViewGroup.LayoutParams.MATCH_PARENT; + cardImage.getLayoutParams().width = cardImage.getContext().getResources() + .getDimensionPixelSize(R.dimen.card_image_horizontal_width); + cardInfo.getLayoutParams().height = ViewGroup.LayoutParams.WRAP_CONTENT; + cardInfo.getLayoutParams().width = ViewGroup.LayoutParams.MATCH_PARENT; + cardImageShape.setTopLeftCorner(CornerFamily.ROUNDED, radius); + cardImageShape.setBottomLeftCorner(CornerFamily.ROUNDED, radius); + } + + cardImage.setShapeAppearanceModel(cardImageShape.build()); + + cardImage.setScaleType(ImageView.ScaleType.CENTER_CROP); + + RequestBuilder builder = Glide.with(cardImage.getContext()) + .load(card.getImage()) + .dontTransform(); + if (statusDisplayOptions.useBlurhash() && !TextUtils.isEmpty(card.getBlurhash())) { + builder = builder.placeholder(decodeBlurHash(card.getBlurhash())); + } + builder.into(cardImage); + } else if (statusDisplayOptions.useBlurhash() && !TextUtils.isEmpty(card.getBlurhash())) { + int radius = cardImage.getContext().getResources() + .getDimensionPixelSize(R.dimen.card_radius); + + cardView.setOrientation(LinearLayout.HORIZONTAL); + cardImage.getLayoutParams().height = ViewGroup.LayoutParams.MATCH_PARENT; + cardImage.getLayoutParams().width = cardImage.getContext().getResources() + .getDimensionPixelSize(R.dimen.card_image_horizontal_width); + cardInfo.getLayoutParams().height = ViewGroup.LayoutParams.WRAP_CONTENT; + cardInfo.getLayoutParams().width = ViewGroup.LayoutParams.MATCH_PARENT; + + ShapeAppearanceModel cardImageShape = ShapeAppearanceModel.builder() + .setTopLeftCorner(CornerFamily.ROUNDED, radius) + .setBottomLeftCorner(CornerFamily.ROUNDED, radius) + .build(); + cardImage.setShapeAppearanceModel(cardImageShape); + + cardImage.setScaleType(ImageView.ScaleType.CENTER_CROP); + + Glide.with(cardImage.getContext()) + .load(decodeBlurHash(card.getBlurhash())) + .dontTransform() + .into(cardImage); + } else { + cardView.setOrientation(LinearLayout.HORIZONTAL); + cardImage.getLayoutParams().height = ViewGroup.LayoutParams.MATCH_PARENT; + cardImage.getLayoutParams().width = cardImage.getContext().getResources() + .getDimensionPixelSize(R.dimen.card_image_horizontal_width); + cardInfo.getLayoutParams().height = ViewGroup.LayoutParams.WRAP_CONTENT; + cardInfo.getLayoutParams().width = ViewGroup.LayoutParams.MATCH_PARENT; + + cardImage.setShapeAppearanceModel(new ShapeAppearanceModel()); + + cardImage.setScaleType(ImageView.ScaleType.CENTER); + + Glide.with(cardImage.getContext()) + .load(ContextCompat.getDrawable(cardImage.getContext(), R.drawable.card_image_placeholder)) + .into(cardImage); + } + + View.OnClickListener visitLink = v -> listener.onViewUrl(card.getUrl()); + + cardView.setOnClickListener(visitLink); + // View embedded photos in our image viewer instead of opening the browser + cardImage.setOnClickListener(card.getType().equals(Card.TYPE_PHOTO) && !TextUtils.isEmpty(card.getEmbedUrl()) ? + v -> cardView.getContext().startActivity(ViewMediaActivity.newSingleImageIntent(cardView.getContext(), card.getEmbedUrl())) : + visitLink); + + cardView.setClipToOutline(true); + } 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); + pollOptions.setVisibility(visibility); + pollButton.setVisibility(visibility); + pollDescription.setVisibility(visibility); + replyButton.setVisibility(visibility); + reblogButton.setVisibility(visibility); + favouriteButton.setVisibility(visibility); + bookmarkButton.setVisibility(visibility); + moreButton.setVisibility(visibility); + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/trending/TrendingFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/trending/TrendingFragment.kt new file mode 100644 index 0000000000..21ade9668f --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/trending/TrendingFragment.kt @@ -0,0 +1,289 @@ +/* Copyright 2023 Tusky Contributors + * + * This file is a part of Tusky. + * + * This program is free software; you can redistribute it and/or modify it under the terms of the + * GNU General Public License as published by the Free Software Foundation; either version 3 of the + * License, or (at your option) any later version. + * + * Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even + * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General + * Public License for more details. + * + * You should have received a copy of the GNU General Public License along with Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.components.trending + +import android.os.Bundle +import android.util.Log +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.view.accessibility.AccessibilityManager +import androidx.core.content.ContextCompat +import androidx.fragment.app.Fragment +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.lifecycleScope +import androidx.preference.PreferenceManager +import androidx.recyclerview.widget.DividerItemDecoration +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import androidx.recyclerview.widget.SimpleItemAnimator +import androidx.swiperefreshlayout.widget.SwipeRefreshLayout.OnRefreshListener +import at.connyduck.sparkbutton.helpers.Utils +import autodispose2.androidx.lifecycle.autoDispose +import com.keylesspalace.tusky.R +import com.keylesspalace.tusky.appstore.EventHub +import com.keylesspalace.tusky.appstore.PreferenceChangedEvent +import com.keylesspalace.tusky.components.trending.viewmodel.TrendingViewModel +import com.keylesspalace.tusky.databinding.FragmentTrendingBinding +import com.keylesspalace.tusky.db.AccountManager +import com.keylesspalace.tusky.di.Injectable +import com.keylesspalace.tusky.di.ViewModelFactory +import com.keylesspalace.tusky.interfaces.ActionButtonActivity +import com.keylesspalace.tusky.interfaces.RefreshableFragment +import com.keylesspalace.tusky.interfaces.ReselectableFragment +import com.keylesspalace.tusky.settings.PrefKeys +import com.keylesspalace.tusky.util.CardViewMode +import com.keylesspalace.tusky.util.StatusDisplayOptions +import com.keylesspalace.tusky.util.hide +import com.keylesspalace.tusky.util.viewBinding +import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch +import javax.inject.Inject + +class TrendingFragment : + Fragment(), + OnRefreshListener, + Injectable, + ReselectableFragment, + RefreshableFragment { + + @Inject + lateinit var viewModelFactory: ViewModelFactory + + @Inject + lateinit var accountManager: AccountManager + + @Inject + lateinit var eventHub: EventHub + + private val viewModel: TrendingViewModel by lazy { + ViewModelProvider(this, viewModelFactory)[TrendingViewModel::class.java] + } + + private val binding by viewBinding(FragmentTrendingBinding::bind) + + private lateinit var adapter: TrendingPagingAdapter + + private var isSwipeToRefreshEnabled = true + private var hideFab = false + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + val arguments = requireArguments() + + viewModel.init() + + isSwipeToRefreshEnabled = arguments.getBoolean(ARG_ENABLE_SWIPE_TO_REFRESH, true) + + val preferences = PreferenceManager.getDefaultSharedPreferences(requireContext()) + val statusDisplayOptions = StatusDisplayOptions( + animateAvatars = preferences.getBoolean(PrefKeys.ANIMATE_GIF_AVATARS, false), + mediaPreviewEnabled = accountManager.activeAccount!!.mediaPreviewEnabled, + useAbsoluteTime = preferences.getBoolean(PrefKeys.ABSOLUTE_TIME_VIEW, false), + showBotOverlay = preferences.getBoolean(PrefKeys.SHOW_BOT_OVERLAY, true), + useBlurhash = preferences.getBoolean(PrefKeys.USE_BLURHASH, true), + cardViewMode = if (preferences.getBoolean( + PrefKeys.SHOW_CARDS_IN_TIMELINES, + false + ) + ) CardViewMode.INDENTED else CardViewMode.NONE, + confirmReblogs = preferences.getBoolean(PrefKeys.CONFIRM_REBLOGS, true), + confirmFavourites = preferences.getBoolean(PrefKeys.CONFIRM_FAVOURITES, false), + hideStats = preferences.getBoolean(PrefKeys.WELLBEING_HIDE_STATS_POSTS, false), + animateEmojis = preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false) + ) + adapter = TrendingPagingAdapter( + statusDisplayOptions, + ) + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View? { + return inflater.inflate(R.layout.fragment_trending, container, false) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + setupSwipeRefreshLayout() + setupRecyclerView() + + adapter.registerAdapterDataObserver(object : RecyclerView.AdapterDataObserver() { + override fun onItemRangeInserted(positionStart: Int, itemCount: Int) { + if (positionStart == 0 && adapter.itemCount != itemCount) { + binding.recyclerView.post { + if (getView() != null) { + if (isSwipeToRefreshEnabled) { + binding.recyclerView.scrollBy( + 0, + Utils.dpToPx(requireContext(), -30) + ) + } else binding.recyclerView.scrollToPosition(0) + } + } + } + } + }) + + if (actionButtonPresent()) { + val preferences = PreferenceManager.getDefaultSharedPreferences(requireContext()) + hideFab = preferences.getBoolean("fabHide", false) + binding.recyclerView.addOnScrollListener(object : RecyclerView.OnScrollListener() { + override fun onScrolled(view: RecyclerView, dx: Int, dy: Int) { + val composeButton = (activity as ActionButtonActivity).actionButton + if (composeButton != null) { + if (hideFab) { + if (dy > 0 && composeButton.isShown) { + composeButton.hide() // hides the button if we're scrolling down + } else if (dy < 0 && !composeButton.isShown) { + composeButton.show() // shows it if we are scrolling up + } + } else if (!composeButton.isShown) { + composeButton.show() + } + } + } + }) + } + + viewLifecycleOwner.lifecycleScope.launch { + viewModel.tags.collectLatest { viewData -> + adapter.submitList(viewData) + clearLoadingState() + } + } + + eventHub.events + .observeOn(AndroidSchedulers.mainThread()) + .autoDispose(this, Lifecycle.Event.ON_DESTROY) + .subscribe { event -> + when (event) { + is PreferenceChangedEvent -> { + onPreferenceChanged(event.preferenceKey) + } + } + } + } + + private fun setupSwipeRefreshLayout() { + binding.swipeRefreshLayout.isEnabled = isSwipeToRefreshEnabled + binding.swipeRefreshLayout.setOnRefreshListener(this) + binding.swipeRefreshLayout.setColorSchemeResources(R.color.tusky_blue) + } + + private fun setupRecyclerView() { +// binding.recyclerView.setAccessibilityDelegateCompat( +// ListStatusAccessibilityDelegate( +// binding.recyclerView, +// LoggingStatusActionListener() +// ) { pos -> +// if (pos in 0 until adapter.itemCount) { +// adapter.peek(pos) +// } else { +// null +// } +// } +// ) + binding.recyclerView.setHasFixedSize(true) + binding.recyclerView.layoutManager = LinearLayoutManager(context) + val divider = DividerItemDecoration(context, RecyclerView.VERTICAL) + binding.recyclerView.addItemDecoration(divider) + + // CWs are expanded without animation, buttons animate itself, we don't need it basically + (binding.recyclerView.itemAnimator as SimpleItemAnimator).supportsChangeAnimations = false + binding.recyclerView.adapter = adapter + } + + override fun onRefresh() { + binding.statusView.hide() + + viewLifecycleOwner.lifecycleScope.launch { + viewModel.invalidate() + clearLoadingState() + } + } + + private fun clearLoadingState() { + binding.swipeRefreshLayout.isRefreshing = false + binding.statusView.hide() + binding.progressBar.hide() + } + + private fun onPreferenceChanged(key: String) { + val sharedPreferences = PreferenceManager.getDefaultSharedPreferences(requireContext()) + when (key) { + PrefKeys.FAB_HIDE -> { + hideFab = sharedPreferences.getBoolean(PrefKeys.FAB_HIDE, false) + } + + PrefKeys.MEDIA_PREVIEW_ENABLED -> { + val enabled = accountManager.activeAccount!!.mediaPreviewEnabled + val oldMediaPreviewEnabled = adapter.mediaPreviewEnabled + if (enabled != oldMediaPreviewEnabled) { + adapter.mediaPreviewEnabled = enabled + adapter.notifyItemRangeChanged(0, adapter.itemCount) + } + } + } + } + + private fun actionButtonPresent(): Boolean { + return activity is ActionButtonActivity + } + + private var talkBackWasEnabled = false + + override fun onResume() { + super.onResume() + val a11yManager = + ContextCompat.getSystemService(requireContext(), AccessibilityManager::class.java) + + val wasEnabled = talkBackWasEnabled + talkBackWasEnabled = a11yManager?.isEnabled == true + Log.d(TAG, "talkback was enabled: $wasEnabled, now $talkBackWasEnabled") + if (talkBackWasEnabled && !wasEnabled) { + adapter.notifyItemRangeChanged(0, adapter.itemCount) + } + } + + override fun onReselect() { + if (isAdded) { + binding.recyclerView.layoutManager?.scrollToPosition(0) + binding.recyclerView.stopScroll() + } + } + + override fun refreshContent() { + onRefresh() + } + + companion object { + private const val TAG = "TrendingF" // logging tag + private const val ARG_ENABLE_SWIPE_TO_REFRESH = "enableSwipeToRefresh" + + fun newInstance(): TrendingFragment { + val fragment = TrendingFragment() + val arguments = Bundle(1) + arguments.putBoolean(ARG_ENABLE_SWIPE_TO_REFRESH, true) + fragment.arguments = arguments + return fragment + } + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/trending/TrendingPagingAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/trending/TrendingPagingAdapter.kt new file mode 100644 index 0000000000..908b064b93 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/trending/TrendingPagingAdapter.kt @@ -0,0 +1,140 @@ +/* Copyright 2023 Tusky Contributors + * + * This file is a part of Tusky. + * + * This program is free software; you can redistribute it and/or modify it under the terms of the + * GNU General Public License as published by the Free Software Foundation; either version 3 of the + * License, or (at your option) any later version. + * + * Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even + * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General + * Public License for more details. + * + * You should have received a copy of the GNU General Public License along with Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.components.trending + +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ListAdapter +import androidx.recyclerview.widget.RecyclerView +import com.keylesspalace.tusky.R +import com.keylesspalace.tusky.adapter.PlaceholderViewHolder +import com.keylesspalace.tusky.adapter.StatusBaseViewHolder +import com.keylesspalace.tusky.adapter.TagViewHolder +import com.keylesspalace.tusky.util.StatusDisplayOptions +import com.keylesspalace.tusky.viewdata.TrendingViewData + +class TrendingPagingAdapter( + private var statusDisplayOptions: StatusDisplayOptions, +) : ListAdapter(TrendingDifferCallback) { + + var mediaPreviewEnabled: Boolean + get() = statusDisplayOptions.mediaPreviewEnabled + set(mediaPreviewEnabled) { + statusDisplayOptions = statusDisplayOptions.copy( + mediaPreviewEnabled = mediaPreviewEnabled + ) + } + + init { + stateRestorationPolicy = StateRestorationPolicy.PREVENT_WHEN_EMPTY + } + + override fun onCreateViewHolder(viewGroup: ViewGroup, viewType: Int): RecyclerView.ViewHolder { + return when (viewType) { + VIEW_TYPE_TAG -> { + val view = LayoutInflater.from(viewGroup.context) + .inflate(R.layout.item_trending, viewGroup, false) + TagViewHolder(view) + } + + VIEW_TYPE_PLACEHOLDER -> { + val view = LayoutInflater.from(viewGroup.context) + .inflate(R.layout.item_trending_placeholder, viewGroup, false) + PlaceholderViewHolder(view) + } + + else -> { + val view = LayoutInflater.from(viewGroup.context) + .inflate(R.layout.item_trending, viewGroup, false) + TagViewHolder(view) + } + } + } + + override fun onBindViewHolder(viewHolder: RecyclerView.ViewHolder, position: Int) { + bindViewHolder(viewHolder, position, null) + } + + override fun onBindViewHolder( + viewHolder: RecyclerView.ViewHolder, + position: Int, + payloads: List<*> + ) { + bindViewHolder(viewHolder, position, payloads) + } + + private fun bindViewHolder( + viewHolder: RecyclerView.ViewHolder, + position: Int, + payloads: List<*>? + ) { + val trending = getItem(position) + if (trending is TrendingViewData.Tag) { + this.currentList + + val maxTrendingValue = currentList + .flatMap { trendingViewData -> + trendingViewData.asTagOrNull()?.tag?.history ?: emptyList() + } + .mapNotNull { it.accounts.toIntOrNull() } + .maxOrNull() ?: 1 + + val holder = viewHolder as TagViewHolder + holder.setup(trending, maxTrendingValue) + } + } + + override fun getItemViewType(position: Int): Int { + return if (getItem(position) is TrendingViewData.Tag) { + VIEW_TYPE_TAG + } else { + VIEW_TYPE_TAG + } + } + + companion object { + private const val VIEW_TYPE_TAG = 0 + private const val VIEW_TYPE_PLACEHOLDER = 2 + + val TrendingDifferCallback = object : DiffUtil.ItemCallback() { + override fun areItemsTheSame( + oldItem: TrendingViewData, + newItem: TrendingViewData + ): Boolean { + return oldItem.id == newItem.id + } + + override fun areContentsTheSame( + oldItem: TrendingViewData, + newItem: TrendingViewData + ): Boolean { + return false // Items are different always. It allows to refresh timestamp on every view holder update + } + + override fun getChangePayload( + oldItem: TrendingViewData, + newItem: TrendingViewData + ): Any? { + return if (oldItem == newItem) { + // If items are equal - update timestamp only + listOf(StatusBaseViewHolder.Key.KEY_CREATED) + } else // If items are different - update the whole view holder + null + } + } + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/trending/viewmodel/TrendingViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/trending/viewmodel/TrendingViewModel.kt new file mode 100644 index 0000000000..22ac60e9e0 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/trending/viewmodel/TrendingViewModel.kt @@ -0,0 +1,62 @@ +/* Copyright 2023 Tusky Contributors + * + * This file is a part of Tusky. + * + * This program is free software; you can redistribute it and/or modify it under the terms of the + * GNU General Public License as published by the Free Software Foundation; either version 3 of the + * License, or (at your option) any later version. + * + * Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even + * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General + * Public License for more details. + * + * You should have received a copy of the GNU General Public License along with Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.components.trending.viewmodel + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.keylesspalace.tusky.db.AccountManager +import com.keylesspalace.tusky.entity.TrendingTag +import com.keylesspalace.tusky.usecase.TrendingCases +import com.keylesspalace.tusky.util.toViewData +import com.keylesspalace.tusky.viewdata.TrendingViewData +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.launch +import javax.inject.Inject + +class TrendingViewModel @Inject constructor( + private val trendingCases: TrendingCases, + protected val accountManager: AccountManager, +) : ViewModel() { + + val tags: MutableStateFlow> = MutableStateFlow(emptyList()) + + private var alwaysShowSensitiveMedia = false + private var alwaysOpenSpoilers = false + + fun init() { + this.alwaysShowSensitiveMedia = accountManager.activeAccount!!.alwaysShowSensitiveMedia + this.alwaysOpenSpoilers = accountManager.activeAccount!!.alwaysOpenSpoiler + + viewModelScope.launch { + invalidate() + } + } + + suspend fun trendingTags(): List { + return trendingCases.trendingTags() + } + + /** Triggered when currently displayed data must be reloaded. */ + suspend fun invalidate() { + val trending = trendingTags() + val viewData = trending.map { it.toViewData() } + tags.emit(viewData) + } + + companion object { + private const val TAG = "TrendingVM" + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/di/FragmentBuildersModule.kt b/app/src/main/java/com/keylesspalace/tusky/di/FragmentBuildersModule.kt index bc202f142b..baf8b5fb04 100644 --- a/app/src/main/java/com/keylesspalace/tusky/di/FragmentBuildersModule.kt +++ b/app/src/main/java/com/keylesspalace/tusky/di/FragmentBuildersModule.kt @@ -30,6 +30,7 @@ import com.keylesspalace.tusky.components.search.fragments.SearchAccountsFragmen import com.keylesspalace.tusky.components.search.fragments.SearchHashtagsFragment import com.keylesspalace.tusky.components.search.fragments.SearchStatusesFragment import com.keylesspalace.tusky.components.timeline.TimelineFragment +import com.keylesspalace.tusky.components.trending.TrendingFragment import com.keylesspalace.tusky.components.viewthread.ViewThreadFragment import com.keylesspalace.tusky.components.viewthread.edits.ViewEditsFragment import com.keylesspalace.tusky.fragment.AccountListFragment @@ -99,4 +100,7 @@ abstract class FragmentBuildersModule { @ContributesAndroidInjector abstract fun listsForAccountFragment(): ListsForAccountFragment + + @ContributesAndroidInjector + abstract fun trendingFragment(): TrendingFragment } diff --git a/app/src/main/java/com/keylesspalace/tusky/di/ViewModelFactory.kt b/app/src/main/java/com/keylesspalace/tusky/di/ViewModelFactory.kt index aab1fa3d32..6026584568 100644 --- a/app/src/main/java/com/keylesspalace/tusky/di/ViewModelFactory.kt +++ b/app/src/main/java/com/keylesspalace/tusky/di/ViewModelFactory.kt @@ -18,6 +18,7 @@ import com.keylesspalace.tusky.components.scheduled.ScheduledStatusViewModel import com.keylesspalace.tusky.components.search.SearchViewModel import com.keylesspalace.tusky.components.timeline.viewmodel.CachedTimelineViewModel import com.keylesspalace.tusky.components.timeline.viewmodel.NetworkTimelineViewModel +import com.keylesspalace.tusky.components.trending.viewmodel.TrendingViewModel import com.keylesspalace.tusky.components.viewthread.ViewThreadViewModel import com.keylesspalace.tusky.components.viewthread.edits.ViewEditsViewModel import com.keylesspalace.tusky.viewmodel.AccountsInListViewModel @@ -144,5 +145,10 @@ abstract class ViewModelModule { @ViewModelKey(ListsForAccountViewModel::class) internal abstract fun listsForAccountViewModel(viewModel: ListsForAccountViewModel): ViewModel + @Binds + @IntoMap + @ViewModelKey(TrendingViewModel::class) + internal abstract fun trendingViewModel(viewModel: TrendingViewModel): ViewModel + // Add more ViewModels here } diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/TrendingTagsResult.kt b/app/src/main/java/com/keylesspalace/tusky/entity/TrendingTagsResult.kt new file mode 100644 index 0000000000..109d045966 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/entity/TrendingTagsResult.kt @@ -0,0 +1,29 @@ +/* Copyright 2023 Tusky Contributors + * + * This file is a part of Tusky. + * + * This program is free software; you can redistribute it and/or modify it under the terms of the + * GNU General Public License as published by the Free Software Foundation; either version 3 of the + * License, or (at your option) any later version. + * + * Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even + * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General + * Public License for more details. + * + * You should have received a copy of the GNU General Public License along with Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.entity + +data class TrendingTag( + val name: String, + val url: String, + val history: List, + val following: Boolean, +) + +data class TrendingTagHistory( + val day: String, + val accounts: String, + val uses: String, +) diff --git a/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.kt b/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.kt index 5633ad3d14..d75d65541c 100644 --- a/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.kt +++ b/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.kt @@ -42,6 +42,7 @@ import com.keylesspalace.tusky.entity.StatusContext import com.keylesspalace.tusky.entity.StatusEdit import com.keylesspalace.tusky.entity.StatusSource import com.keylesspalace.tusky.entity.TimelineAccount +import com.keylesspalace.tusky.entity.TrendingTag import io.reactivex.rxjava3.core.Single import okhttp3.MultipartBody import okhttp3.RequestBody @@ -709,4 +710,7 @@ interface MastodonApi { @POST("api/v1/tags/{name}/unfollow") suspend fun unfollowTag(@Path("name") name: String): NetworkResult + + @GET("api/v1/trends/tags") + suspend fun trendingTags(): NetworkResult> } diff --git a/app/src/main/java/com/keylesspalace/tusky/usecase/TrendingCases.kt b/app/src/main/java/com/keylesspalace/tusky/usecase/TrendingCases.kt new file mode 100644 index 0000000000..2e08876eb0 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/usecase/TrendingCases.kt @@ -0,0 +1,44 @@ +/* Copyright 2023 Tusky Contributors + * + * This file is a part of Tusky. + * + * This program is free software; you can redistribute it and/or modify it under the terms of the + * GNU General Public License as published by the Free Software Foundation; either version 3 of the + * License, or (at your option) any later version. + * + * Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even + * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General + * Public License for more details. + * + * You should have received a copy of the GNU General Public License along with Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.usecase + +import android.util.Log +import com.keylesspalace.tusky.entity.TrendingTag +import com.keylesspalace.tusky.network.MastodonApi +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import javax.inject.Inject + +/** + * Created by @knossos@fosstodon.org on 2023-01-07. + */ +class TrendingCases @Inject constructor( + private val mastodonApi: MastodonApi, +) { + suspend fun trendingTags(): List { + val tags = withContext(Dispatchers.IO) { + mastodonApi.trendingTags().getOrNull() ?: emptyList() + } + + Log.v(TAG, "Trending tags: ${tags.map { it.name }}") + + return tags + } + + companion object { + private const val TAG = "TrendingCases" + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/util/ViewDataUtils.kt b/app/src/main/java/com/keylesspalace/tusky/util/ViewDataUtils.kt index bc40cdd6e7..2b4970595d 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/ViewDataUtils.kt +++ b/app/src/main/java/com/keylesspalace/tusky/util/ViewDataUtils.kt @@ -18,8 +18,10 @@ package com.keylesspalace.tusky.util import com.keylesspalace.tusky.entity.Notification import com.keylesspalace.tusky.entity.Status +import com.keylesspalace.tusky.entity.TrendingTag import com.keylesspalace.tusky.viewdata.NotificationViewData import com.keylesspalace.tusky.viewdata.StatusViewData +import com.keylesspalace.tusky.viewdata.TrendingViewData @JvmName("statusToViewData") fun Status.toViewData( @@ -51,3 +53,10 @@ fun Notification.toViewData( this.report, ) } + +@JvmName("tagToViewData") +fun TrendingTag.toViewData(): TrendingViewData.Tag { + return TrendingViewData.Tag( + tag = this, + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/view/GraphView.kt b/app/src/main/java/com/keylesspalace/tusky/view/GraphView.kt new file mode 100644 index 0000000000..92310a8cee --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/view/GraphView.kt @@ -0,0 +1,206 @@ +/* Copyright 2023 Tusky Contributors + * + * This file is a part of Tusky. + * + * This program is free software; you can redistribute it and/or modify it under the terms of the + * GNU General Public License as published by the Free Software Foundation; either version 3 of the + * License, or (at your option) any later version. + * + * Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even + * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General + * Public License for more details. + * + * You should have received a copy of the GNU General Public License along with Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.view + +import android.content.Context +import android.graphics.Canvas +import android.graphics.Paint +import android.graphics.Path +import android.graphics.Rect +import android.util.AttributeSet +import androidx.annotation.ColorInt +import androidx.annotation.Dimension +import androidx.appcompat.widget.AppCompatImageView +import androidx.core.content.ContextCompat +import com.keylesspalace.tusky.R +import kotlin.math.max +import kotlin.random.Random + + +class GraphView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0, + defStyleRes: Int = 0, +) : AppCompatImageView(context, attrs, defStyleAttr) { + @get:ColorInt + @ColorInt + var lineColor = 0 + + @get:Dimension + var lineThickness = 0f + + @get:ColorInt + @ColorInt + var fillColor = 0 + + @get:ColorInt + @ColorInt + var graphColor = 0 + + var proportionalTrending = false + + private lateinit var linePaint: Paint + private lateinit var fillPaint: Paint + private lateinit var graphPaint: Paint + + private lateinit var sizeRect: Rect + private var linePath: Path = Path() + private var fillPath: Path = Path() + + var maxTrendingValue: Int = 300 + var data: List = if (isInEditMode) listOf( + Random.nextInt(300), + Random.nextInt(300), + Random.nextInt(300), + Random.nextInt(300), + Random.nextInt(300), + Random.nextInt(300), + Random.nextInt(300), + ) else listOf( + 1, 1, 1, 1, 1, 1, 1, + ) + set(value) { + field = value.map { max(1, it) } + + if (linePath.isEmpty && width > 0) { + initializeVertices() + invalidate() + } + } + + init { + initFromXML(attrs) + } + + private fun initFromXML(attr: AttributeSet?) { + val a = context.obtainStyledAttributes(attr, R.styleable.GraphView) + + lineColor = ContextCompat.getColor( + context, + a.getResourceId( + R.styleable.GraphView_lineColor, + R.color.tusky_blue_light, + ) + ) + + lineThickness = resources.getDimension( + a.getResourceId( + R.styleable.GraphView_lineThickness, + R.dimen.graph_line_thickness, + ) + ) + + fillColor = ContextCompat.getColor( + context, + a.getResourceId( + R.styleable.GraphView_fillColor, + R.color.tusky_blue, + ) + ) + + graphColor = ContextCompat.getColor( + context, + a.getResourceId( + R.styleable.GraphView_graphColor, + R.color.colorBackground, + ) + ) + + proportionalTrending = a.getBoolean( + R.styleable.GraphView_proportionalTrending, + proportionalTrending, + ) + + linePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = lineColor + + style = Paint.Style.STROKE; + strokeJoin = Paint.Join.MITER; + strokeCap = Paint.Cap.SQUARE; + } + + fillPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = fillColor + + style = Paint.Style.FILL; + strokeJoin = Paint.Join.MITER; + strokeCap = Paint.Cap.SQUARE; + } + + graphPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = graphColor + } + + a.recycle() + } + + private fun initializeVertices() { + sizeRect = Rect(0, 0, width, height) + + val max = if (proportionalTrending) { + maxTrendingValue + } else { + max(data.max(), 1) + } + val mainRatio = height.toFloat() / max.toFloat() + val pointDistance = width.toFloat() / max(data.size - 1, 1).toFloat() + + data.forEachIndexed { index, magnitude -> + val x = pointDistance * index.toFloat() + + val ratio = magnitude.toFloat() / max.toFloat() + + val y = height.toFloat() - (magnitude.toFloat() * ratio * mainRatio) + + if (index == 0) { + linePath.reset() + linePath.moveTo(x, y) + fillPath.reset() + fillPath.moveTo(x, y) + } else { + linePath.lineTo(x, y) + fillPath.lineTo(x, y) + } + } + + fillPath.lineTo(width.toFloat(), height.toFloat()) + fillPath.lineTo(0f, height.toFloat()) + fillPath.lineTo(0f, height.toFloat() - data.first()) + } + + override fun onDraw(canvas: Canvas?) { + super.onDraw(canvas) + + if (linePath.isEmpty && width > 0) { + initializeVertices() + } + + canvas?.apply { + drawRect(sizeRect, graphPaint) + + drawPath( + fillPath, + fillPaint, + ) + + drawPath( + linePath, + linePaint, + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/viewdata/TrendingViewData.kt b/app/src/main/java/com/keylesspalace/tusky/viewdata/TrendingViewData.kt new file mode 100644 index 0000000000..346dd3150d --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/viewdata/TrendingViewData.kt @@ -0,0 +1,37 @@ +/* Copyright 2023 Tusky Contributors + * + * This file is a part of Tusky. + * + * This program is free software; you can redistribute it and/or modify it under the terms of the + * GNU General Public License as published by the Free Software Foundation; either version 3 of the + * License, or (at your option) any later version. + * + * Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even + * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General + * Public License for more details. + * + * You should have received a copy of the GNU General Public License along with Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.viewdata + +import com.keylesspalace.tusky.entity.TrendingTag + +/** + * Created by charlag on 11/07/2017. + * + * Class to represent data required to display either a notification or a placeholder. + * It is either a [TrendingViewData.Concrete] or a [TrendingViewData.Placeholder]. + */ +sealed class TrendingViewData { + abstract val id: String + + data class Tag( + val tag: TrendingTag + ) : TrendingViewData() { + override val id: String + get() = tag.name + } + + fun asTagOrNull() = this as? Tag +} diff --git a/app/src/main/res/drawable/ic_trending_up_24px.xml b/app/src/main/res/drawable/ic_trending_up_24px.xml new file mode 100644 index 0000000000..95e98c2104 --- /dev/null +++ b/app/src/main/res/drawable/ic_trending_up_24px.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/layout/fragment_trending.xml b/app/src/main/res/layout/fragment_trending.xml new file mode 100644 index 0000000000..d3e716d6ba --- /dev/null +++ b/app/src/main/res/layout/fragment_trending.xml @@ -0,0 +1,54 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/item_trending.xml b/app/src/main/res/layout/item_trending.xml new file mode 100644 index 0000000000..35b8eb3bc5 --- /dev/null +++ b/app/src/main/res/layout/item_trending.xml @@ -0,0 +1,64 @@ + + + + + + + + + + diff --git a/app/src/main/res/layout/item_trending_placeholder.xml b/app/src/main/res/layout/item_trending_placeholder.xml new file mode 100644 index 0000000000..fdaeb9b37a --- /dev/null +++ b/app/src/main/res/layout/item_trending_placeholder.xml @@ -0,0 +1,23 @@ + + + +