From 7623962a0da1df68f8b955d02c5ebe5ef94cee48 Mon Sep 17 00:00:00 2001 From: Ivan Kupalov Date: Mon, 30 Dec 2019 21:37:20 +0100 Subject: [PATCH] Use blurhash as image preview and as sensitive media cover, close #1571 (#1581) * Use blurhash as image preview and as sensitive media cover, close #1571 * Fix focal point for blurhashes * Fix video indicator overlapping sensitive media indicator * Add a preference for blurhash * Add blurhash to report UI. * Introduce StatusDisplayOptions --- .../tusky/PreferencesActivity.kt | 5 +- .../tusky/adapter/NotificationsAdapter.java | 104 +++++------ .../tusky/adapter/StatusBaseViewHolder.java | 162 ++++++++++-------- .../adapter/StatusDetailedViewHolder.java | 18 +- .../tusky/adapter/StatusViewHolder.java | 18 +- .../tusky/adapter/ThreadAdapter.java | 44 ++--- .../tusky/adapter/TimelineAdapter.java | 68 ++++---- .../conversation/ConversationAdapter.kt | 20 ++- .../conversation/ConversationViewHolder.java | 33 ++-- .../conversation/ConversationsFragment.kt | 14 +- .../report/adapter/StatusViewHolder.kt | 21 +-- .../report/adapter/StatusesAdapter.kt | 17 +- .../fragments/ReportStatusesFragment.kt | 17 +- .../search/adapter/SearchStatusesAdapter.kt | 16 +- .../fragments/SearchStatusesFragment.kt | 13 +- .../keylesspalace/tusky/entity/Attachment.kt | 3 +- .../tusky/fragment/NotificationsFragment.java | 19 +- .../tusky/fragment/TimelineFragment.java | 21 +-- .../tusky/fragment/ViewThreadFragment.java | 23 ++- .../tusky/util/BlurHashDecoder.kt | 130 ++++++++++++++ .../tusky/util/ImageLoadingHelper.kt | 8 +- .../tusky/util/StatusDisplayOptions.kt | 14 ++ .../tusky/util/StatusViewHelper.kt | 75 ++++---- .../tusky/view/MediaPreviewImageView.kt | 2 +- .../main/res/drawable/media_warning_bg.xml | 5 + .../main/res/layout/item_report_status.xml | 16 +- app/src/main/res/layout/item_status.xml | 17 +- .../main/res/layout/item_status_detailed.xml | 10 +- app/src/main/res/values-night/styles.xml | 2 +- app/src/main/res/values/strings.xml | 1 + app/src/main/res/values/styles.xml | 6 +- app/src/main/res/xml/preferences.xml | 6 + 32 files changed, 560 insertions(+), 368 deletions(-) create mode 100644 app/src/main/java/com/keylesspalace/tusky/util/BlurHashDecoder.kt create mode 100644 app/src/main/java/com/keylesspalace/tusky/util/StatusDisplayOptions.kt create mode 100644 app/src/main/res/drawable/media_warning_bg.xml diff --git a/app/src/main/java/com/keylesspalace/tusky/PreferencesActivity.kt b/app/src/main/java/com/keylesspalace/tusky/PreferencesActivity.kt index b68b339a..4fd0b5c6 100644 --- a/app/src/main/java/com/keylesspalace/tusky/PreferencesActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/PreferencesActivity.kt @@ -56,7 +56,7 @@ class PreferencesActivity : BaseActivity(), SharedPreferences.OnSharedPreference setDisplayShowHomeEnabled(true) } - val fragment: Fragment = when(intent.getIntExtra(EXTRA_PREFERENCE_TYPE, 0)) { + val fragment: Fragment = when (intent.getIntExtra(EXTRA_PREFERENCE_TYPE, 0)) { GENERAL_PREFERENCES -> { setTitle(R.string.action_view_preferences) PreferencesFragment.newInstance() @@ -128,7 +128,8 @@ class PreferencesActivity : BaseActivity(), SharedPreferences.OnSharedPreference this.restartCurrentActivity() } - "statusTextSize", "absoluteTimeView", "showBotOverlay", "animateGifAvatars" -> { + "statusTextSize", "absoluteTimeView", "showBotOverlay", "animateGifAvatars", + "useBlurhash" -> { restartActivitiesOnExit = true } "language" -> { diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/NotificationsAdapter.java b/app/src/main/java/com/keylesspalace/tusky/adapter/NotificationsAdapter.java index 0602eb10..433676f0 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/NotificationsAdapter.java +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/NotificationsAdapter.java @@ -33,6 +33,12 @@ import android.widget.ImageView; import android.widget.TextView; import android.widget.ToggleButton; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.core.content.ContextCompat; +import androidx.core.text.BidiFormatter; +import androidx.recyclerview.widget.RecyclerView; + import com.keylesspalace.tusky.R; import com.keylesspalace.tusky.entity.Account; import com.keylesspalace.tusky.entity.Emoji; @@ -40,10 +46,11 @@ import com.keylesspalace.tusky.entity.Notification; import com.keylesspalace.tusky.interfaces.LinkListener; import com.keylesspalace.tusky.interfaces.StatusActionListener; import com.keylesspalace.tusky.util.CustomEmojiHelper; -import com.keylesspalace.tusky.util.TimestampUtils; import com.keylesspalace.tusky.util.ImageLoadingHelper; import com.keylesspalace.tusky.util.LinkHelper; import com.keylesspalace.tusky.util.SmartLengthInputFilter; +import com.keylesspalace.tusky.util.StatusDisplayOptions; +import com.keylesspalace.tusky.util.TimestampUtils; import com.keylesspalace.tusky.viewdata.NotificationViewData; import com.keylesspalace.tusky.viewdata.StatusViewData; import com.mikepenz.iconics.utils.Utils; @@ -53,12 +60,6 @@ import java.util.Date; import java.util.List; import java.util.Locale; -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.core.content.ContextCompat; -import androidx.core.text.BidiFormatter; -import androidx.recyclerview.widget.RecyclerView; - public class NotificationsAdapter extends RecyclerView.Adapter { public interface AdapterDataSource { @@ -78,28 +79,23 @@ public class NotificationsAdapter extends RecyclerView.Adapter { private static final InputFilter[] NO_INPUT_FILTER = new InputFilter[0]; private String accountId; + private StatusDisplayOptions statusDisplayOptions; private StatusActionListener statusListener; private NotificationActionListener notificationActionListener; - private boolean mediaPreviewEnabled; - private boolean useAbsoluteTime; - private boolean showBotOverlay; - private boolean animateAvatar; private BidiFormatter bidiFormatter; private AdapterDataSource dataSource; public NotificationsAdapter(String accountId, AdapterDataSource dataSource, + StatusDisplayOptions statusDisplayOptions, StatusActionListener statusListener, NotificationActionListener notificationActionListener) { this.accountId = accountId; this.dataSource = dataSource; + this.statusDisplayOptions = statusDisplayOptions; this.statusListener = statusListener; this.notificationActionListener = notificationActionListener; - mediaPreviewEnabled = true; - useAbsoluteTime = false; - showBotOverlay = true; - animateAvatar = false; bidiFormatter = BidiFormatter.getInstance(); } @@ -108,20 +104,20 @@ public class NotificationsAdapter extends RecyclerView.Adapter { public RecyclerView.ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { LayoutInflater inflater = LayoutInflater.from(parent.getContext()); switch (viewType) { - case VIEW_TYPE_STATUS: { + case VIEW_TYPE_STATUS: { View view = inflater .inflate(R.layout.item_status, parent, false); - return new StatusViewHolder(view, useAbsoluteTime); + return new StatusViewHolder(view); } case VIEW_TYPE_STATUS_NOTIFICATION: { View view = inflater .inflate(R.layout.item_status_notification, parent, false); - return new StatusNotificationViewHolder(view, useAbsoluteTime, animateAvatar); + return new StatusNotificationViewHolder(view, statusDisplayOptions); } case VIEW_TYPE_FOLLOW: { View view = inflater .inflate(R.layout.item_follow, parent, false); - return new FollowViewHolder(view, animateAvatar); + return new FollowViewHolder(view, statusDisplayOptions); } case VIEW_TYPE_PLACEHOLDER: { View view = inflater @@ -137,7 +133,8 @@ public class NotificationsAdapter extends RecyclerView.Adapter { Utils.convertDpToPx(parent.getContext(), 24) ) ); - return new RecyclerView.ViewHolder(view) {}; + return new RecyclerView.ViewHolder(view) { + }; } } } @@ -171,8 +168,8 @@ public class NotificationsAdapter extends RecyclerView.Adapter { StatusViewHolder holder = (StatusViewHolder) viewHolder; StatusViewData.Concrete status = concreteNotificaton.getStatusViewData(); holder.setupWithStatus(status, - statusListener, mediaPreviewEnabled, showBotOverlay, animateAvatar, payloadForHolder); - if(concreteNotificaton.getType() == Notification.Type.POLL) { + statusListener, statusDisplayOptions, payloadForHolder); + if (concreteNotificaton.getType() == Notification.Type.POLL) { holder.setPollInfo(accountId.equals(concreteNotificaton.getAccount().getId())); } else { holder.hideStatusInfo(); @@ -202,7 +199,7 @@ public class NotificationsAdapter extends RecyclerView.Adapter { concreteNotificaton.getId()); } else { if (payloadForHolder instanceof List) - for (Object item : (List)payloadForHolder) { + for (Object item : (List) payloadForHolder) { if (StatusBaseViewHolder.Key.KEY_CREATED.equals(item)) { holder.setCreatedAt(statusViewData.getCreatedAt()); } @@ -221,7 +218,6 @@ public class NotificationsAdapter extends RecyclerView.Adapter { default: } } - } @Override @@ -229,6 +225,20 @@ public class NotificationsAdapter extends RecyclerView.Adapter { return dataSource.getItemCount(); } + public void setMediaPreviewEnabled(boolean mediaPreviewEnabled) { + this.statusDisplayOptions = statusDisplayOptions.copy( + statusDisplayOptions.animateAvatars(), + mediaPreviewEnabled, + statusDisplayOptions.useAbsoluteTime(), + statusDisplayOptions.showBotOverlay(), + statusDisplayOptions.useBlurhash() + ); + } + + public boolean isMediaPreviewEnabled() { + return this.statusDisplayOptions.mediaPreviewEnabled(); + } + @Override public int getItemViewType(int position) { NotificationViewData notification = dataSource.getItemAt(position); @@ -256,26 +266,7 @@ public class NotificationsAdapter extends RecyclerView.Adapter { throw new AssertionError("Unknown notification type"); } - } - public void setMediaPreviewEnabled(boolean enabled) { - mediaPreviewEnabled = enabled; - } - - public boolean isMediaPreviewEnabled() { - return mediaPreviewEnabled; - } - - public void setUseAbsoluteTime(boolean useAbsoluteTime) { - this.useAbsoluteTime = useAbsoluteTime; - } - - public void setShowBotOverlay(boolean showBotOverlay) { - this.showBotOverlay = showBotOverlay; - } - - public void setAnimateAvatar(boolean animateAvatar) { - this.animateAvatar = animateAvatar; } public interface NotificationActionListener { @@ -300,15 +291,15 @@ public class NotificationsAdapter extends RecyclerView.Adapter { private TextView usernameView; private TextView displayNameView; private ImageView avatar; - private boolean animateAvatar; + private StatusDisplayOptions statusDisplayOptions; - FollowViewHolder(View itemView, boolean animateAvatar) { + FollowViewHolder(View itemView, StatusDisplayOptions statusDisplayOptions) { super(itemView); message = itemView.findViewById(R.id.notification_text); usernameView = itemView.findViewById(R.id.notification_username); displayNameView = itemView.findViewById(R.id.notification_display_name); avatar = itemView.findViewById(R.id.notification_avatar); - this.animateAvatar = animateAvatar; + this.statusDisplayOptions = statusDisplayOptions; } void setMessage(Account account, BidiFormatter bidiFormatter) { @@ -330,7 +321,8 @@ public class NotificationsAdapter extends RecyclerView.Adapter { int avatarRadius = avatar.getContext().getResources() .getDimensionPixelSize(R.dimen.avatar_radius_42dp); - ImageLoadingHelper.loadAvatar(account.getAvatar(), avatar, avatarRadius, animateAvatar); + ImageLoadingHelper.loadAvatar(account.getAvatar(), avatar, avatarRadius, + statusDisplayOptions.animateAvatars()); } @@ -352,18 +344,16 @@ public class NotificationsAdapter extends RecyclerView.Adapter { private final TextView contentWarningDescriptionTextView; private final ToggleButton contentWarningButton; private final ToggleButton contentCollapseButton; // TODO: This code SHOULD be based on StatusBaseViewHolder + private StatusDisplayOptions statusDisplayOptions; private String accountId; private String notificationId; private NotificationActionListener notificationActionListener; private StatusViewData.Concrete statusViewData; - - private boolean useAbsoluteTime; - private boolean animateAvatar; private SimpleDateFormat shortSdf; private SimpleDateFormat longSdf; - StatusNotificationViewHolder(View itemView, boolean useAbsoluteTime, boolean animateAvatar) { + StatusNotificationViewHolder(View itemView, StatusDisplayOptions statusDisplayOptions) { super(itemView); message = itemView.findViewById(R.id.notification_top_text); statusNameBar = itemView.findViewById(R.id.status_name_bar); @@ -376,6 +366,7 @@ public class NotificationsAdapter extends RecyclerView.Adapter { contentWarningDescriptionTextView = itemView.findViewById(R.id.notification_content_warning_description); contentWarningButton = itemView.findViewById(R.id.notification_content_warning_button); contentCollapseButton = itemView.findViewById(R.id.button_toggle_notification_content); + this.statusDisplayOptions = statusDisplayOptions; int darkerFilter = Color.rgb(123, 123, 123); statusAvatar.setColorFilter(darkerFilter, PorterDuff.Mode.MULTIPLY); @@ -385,9 +376,6 @@ public class NotificationsAdapter extends RecyclerView.Adapter { message.setOnClickListener(this); statusContent.setOnClickListener(this); contentWarningButton.setOnCheckedChangeListener(this); - - this.useAbsoluteTime = useAbsoluteTime; - this.animateAvatar = animateAvatar; shortSdf = new SimpleDateFormat("HH:mm:ss", Locale.getDefault()); longSdf = new SimpleDateFormat("MM/dd HH:mm:ss", Locale.getDefault()); } @@ -414,7 +402,7 @@ public class NotificationsAdapter extends RecyclerView.Adapter { } protected void setCreatedAt(@Nullable Date createdAt) { - if (useAbsoluteTime) { + if (statusDisplayOptions.useAbsoluteTime()) { String time; if (createdAt != null) { if (System.currentTimeMillis() - createdAt.getTime() > 86400000L) { @@ -511,13 +499,13 @@ public class NotificationsAdapter extends RecyclerView.Adapter { .getDimensionPixelSize(R.dimen.avatar_radius_36dp); ImageLoadingHelper.loadAvatar(statusAvatarUrl, - statusAvatar, statusAvatarRadius, animateAvatar); + statusAvatar, statusAvatarRadius, statusDisplayOptions.animateAvatars()); int notificationAvatarRadius = statusAvatar.getContext().getResources() .getDimensionPixelSize(R.dimen.avatar_radius_24dp); - ImageLoadingHelper.loadAvatar(notificationAvatarUrl, - notificationAvatar, notificationAvatarRadius, animateAvatar); + ImageLoadingHelper.loadAvatar(notificationAvatarUrl, notificationAvatar, + notificationAvatarRadius, statusDisplayOptions.animateAvatars()); } @Override diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/StatusBaseViewHolder.java b/app/src/main/java/com/keylesspalace/tusky/adapter/StatusBaseViewHolder.java index 3bb83dfd..51b655b6 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/StatusBaseViewHolder.java +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/StatusBaseViewHolder.java @@ -1,6 +1,8 @@ package com.keylesspalace.tusky.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; @@ -32,6 +34,7 @@ import com.keylesspalace.tusky.util.CustomEmojiHelper; import com.keylesspalace.tusky.util.HtmlUtils; import com.keylesspalace.tusky.util.ImageLoadingHelper; import com.keylesspalace.tusky.util.LinkHelper; +import com.keylesspalace.tusky.util.StatusDisplayOptions; import com.keylesspalace.tusky.util.ThemeUtils; import com.keylesspalace.tusky.util.TimestampUtils; import com.keylesspalace.tusky.view.MediaPreviewImageView; @@ -49,7 +52,6 @@ import java.util.Locale; import java.util.Objects; import at.connyduck.sparkbutton.SparkButton; -import at.connyduck.sparkbutton.SparkEventListener; import kotlin.collections.CollectionsKt; import static com.keylesspalace.tusky.viewdata.PollViewDataKt.buildDescription; @@ -95,10 +97,9 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { private int avatarRadius36dp; private int avatarRadius24dp; - private final int mediaPreviewUnloadedId; + private final Drawable mediaPreviewUnloaded; - protected StatusBaseViewHolder(View itemView, - boolean useAbsoluteTime) { + protected StatusBaseViewHolder(View itemView) { super(itemView); displayName = itemView.findViewById(R.id.status_display_name); username = itemView.findViewById(R.id.status_username); @@ -152,8 +153,10 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { this.avatarRadius36dp = itemView.getContext().getResources().getDimensionPixelSize(R.dimen.avatar_radius_36dp); this.avatarRadius24dp = itemView.getContext().getResources().getDimensionPixelSize(R.dimen.avatar_radius_24dp); - mediaPreviewUnloadedId = ThemeUtils.getDrawableId(itemView.getContext(), - R.attr.media_preview_unloaded_drawable, android.R.color.black); + mediaPreviewUnloaded = itemView.getContext().getDrawable( + ThemeUtils.getDrawableId(itemView.getContext(), + R.attr.media_preview_unloaded_drawable, android.R.color.black) + ); } protected abstract int getMediaPreviewHeight(Context context); @@ -234,14 +237,13 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { private void setAvatar(String url, @Nullable String rebloggedUrl, boolean isBot, - boolean showBotOverlay, - boolean animateAvatar) { + StatusDisplayOptions statusDisplayOptions) { int avatarRadius; if (TextUtils.isEmpty(rebloggedUrl)) { avatar.setPaddingRelative(0, 0, 0, 0); - if (showBotOverlay && isBot) { + if (statusDisplayOptions.showBotOverlay() && isBot) { avatarInset.setVisibility(View.VISIBLE); avatarInset.setBackgroundColor(0x50ffffff); Glide.with(avatarInset) @@ -260,12 +262,14 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { avatarInset.setVisibility(View.VISIBLE); avatarInset.setBackground(null); - ImageLoadingHelper.loadAvatar(rebloggedUrl, avatarInset, avatarRadius24dp, animateAvatar); + ImageLoadingHelper.loadAvatar(rebloggedUrl, avatarInset, avatarRadius24dp, + statusDisplayOptions.animateAvatars()); avatarRadius = avatarRadius36dp; } - ImageLoadingHelper.loadAvatar(url, avatar, avatarRadius, animateAvatar); + ImageLoadingHelper.loadAvatar(url, avatar, avatarRadius, + statusDisplayOptions.animateAvatars()); } @@ -273,7 +277,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { if (useAbsoluteTime) { timestampInfo.setText(getAbsoluteTime(createdAt)); } else { - if(createdAt == null) { + if (createdAt == null) { timestampInfo.setText("?m"); } else { long then = createdAt.getTime(); @@ -285,7 +289,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { } private String getAbsoluteTime(Date createdAt) { - if(createdAt == null) { + if (createdAt == null) { return "??:??:??"; } if (DateUtils.isToday(createdAt.getTime())) { @@ -302,7 +306,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { /* This one is for screen-readers. Frequently, they would mispronounce timestamps like "17m" * as 17 meters instead of minutes. */ - if(createdAt == null) { + if (createdAt == null) { return "? minutes"; } else { long then = createdAt.getTime(); @@ -367,12 +371,22 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { bookmarkButton.setChecked(bookmarked); } - private void loadImage(MediaPreviewImageView imageView, String previewUrl, MetaData meta) { + private BitmapDrawable decodeBlurHash(String blurhash) { + return ImageLoadingHelper.decodeBlurHash(this.avatar.getContext(), blurhash); + } + + private void loadImage(MediaPreviewImageView imageView, String previewUrl, MetaData meta, + @Nullable String blurhash) { + Drawable placeholder = blurhash != null ? decodeBlurHash(blurhash) : mediaPreviewUnloaded; if (TextUtils.isEmpty(previewUrl)) { - Glide.with(imageView) - .load(mediaPreviewUnloadedId) - .centerInside() - .into(imageView); + if (blurhash != null) { + imageView.setImageDrawable(decodeBlurHash(blurhash)); + } else { + Glide.with(imageView) + .load(placeholder) + .centerInside() + .into(imageView); + } } else { Focus focus = meta != null ? meta.getFocus() : null; @@ -381,7 +395,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { Glide.with(imageView) .load(previewUrl) - .placeholder(mediaPreviewUnloadedId) + .placeholder(placeholder) .centerInside() .addListener(imageView) .into(imageView); @@ -390,7 +404,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { Glide.with(imageView) .load(previewUrl) - .placeholder(mediaPreviewUnloadedId) + .placeholder(placeholder) .centerInside() .into(imageView); } @@ -398,39 +412,11 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { } protected void setMediaPreviews(final List attachments, boolean sensitive, - final StatusActionListener listener, boolean showingContent) { + final StatusActionListener listener, boolean showingContent, + boolean useBlurhash) { Context context = itemView.getContext(); final int n = Math.min(attachments.size(), Status.MAX_MEDIA_ATTACHMENTS); - for (int i = 0; i < n; i++) { - String previewUrl = attachments.get(i).getPreviewUrl(); - String description = attachments.get(i).getDescription(); - MediaPreviewImageView imageView = mediaPreviews[i]; - - imageView.setVisibility(View.VISIBLE); - - if (TextUtils.isEmpty(description)) { - imageView.setContentDescription(imageView.getContext() - .getString(R.string.action_view_media)); - } else { - imageView.setContentDescription(description); - } - - if (!sensitive || showingContent) { - loadImage(imageView, previewUrl, attachments.get(i).getMeta()); - } else { - imageView.setImageResource(mediaPreviewUnloadedId); - } - - final Attachment.Type type = attachments.get(i).getType(); - if (type == Attachment.Type.VIDEO || type == Attachment.Type.GIFV) { - mediaOverlays[i].setVisibility(View.VISIBLE); - } else { - mediaOverlays[i].setVisibility(View.GONE); - } - - setAttachmentClickListener(imageView, listener, i, attachments.get(i), true); - } final int mediaPreviewHeight = getMediaPreviewHeight(context); @@ -444,15 +430,51 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { mediaPreviews[3].getLayoutParams().height = mediaPreviewHeight; } + for (int i = 0; i < n; i++) { + Attachment attachment = attachments.get(i); + String previewUrl = attachment.getPreviewUrl(); + String description = attachment.getDescription(); + MediaPreviewImageView imageView = mediaPreviews[i]; + + imageView.setVisibility(View.VISIBLE); + + if (TextUtils.isEmpty(description)) { + imageView.setContentDescription(imageView.getContext() + .getString(R.string.action_view_media)); + } else { + imageView.setContentDescription(description); + } + + if (showingContent) { + loadImage(imageView, previewUrl, attachment.getMeta(), attachment.getBlurhash()); + } else { + imageView.setFocalPoint(null); + imageView.setScaleType(ImageView.ScaleType.CENTER_CROP); + if (useBlurhash && attachment.getBlurhash() != null) { + BitmapDrawable blurhashBitmap = decodeBlurHash(attachment.getBlurhash()); + imageView.setImageDrawable(blurhashBitmap); + } else { + imageView.setImageDrawable(new ColorDrawable(ThemeUtils.getColor( + context, R.attr.sensitive_media_warning_background_color))); + } + } + + final Attachment.Type type = attachment.getType(); + if (showingContent && (type == Attachment.Type.VIDEO || type == Attachment.Type.GIFV)) { + mediaOverlays[i].setVisibility(View.VISIBLE); + } else { + mediaOverlays[i].setVisibility(View.GONE); + } + + setAttachmentClickListener(imageView, listener, i, attachment, true); + } + + final String hiddenContentText; if (sensitive) { - hiddenContentText = context.getString(R.string.status_sensitive_media_template, - context.getString(R.string.status_sensitive_media_title), - context.getString(R.string.status_sensitive_media_directions)); + hiddenContentText = context.getString(R.string.status_sensitive_media_title); } else { - hiddenContentText = context.getString(R.string.status_sensitive_media_template, - context.getString(R.string.status_media_hidden_title), - context.getString(R.string.status_sensitive_media_directions)); + hiddenContentText = context.getString(R.string.status_media_hidden_title); } sensitiveMediaWarning.setText(HtmlUtils.fromHtml(hiddenContentText)); @@ -528,7 +550,11 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { view.setOnClickListener(v -> { int position = getAdapterPosition(); if (position != RecyclerView.NO_POSITION) { - listener.onViewMedia(position, index, animateTransition ? v : null); + if (sensitiveMediaWarning.getVisibility() == View.VISIBLE) { + listener.onContentHiddenChange(true, getAdapterPosition()); + } else { + listener.onViewMedia(position, index, animateTransition ? v : null); + } } }); view.setOnLongClickListener(v -> { @@ -540,7 +566,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { private static CharSequence getAttachmentDescription(Context context, Attachment attachment) { String duration = ""; - if(attachment.getMeta() != null && attachment.getMeta().getDuration() != null && attachment.getMeta().getDuration() > 0) { + if (attachment.getMeta() != null && attachment.getMeta().getDuration() != null && attachment.getMeta().getDuration() > 0) { duration = formatDuration(attachment.getMeta().getDuration()) + " "; } if (TextUtils.isEmpty(attachment.getDescription())) { @@ -608,29 +634,27 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { } public void setupWithStatus(StatusViewData.Concrete status, final StatusActionListener listener, - boolean mediaPreviewEnabled, boolean showBotOverlay, boolean animateAvatar) { - this.setupWithStatus(status, listener, mediaPreviewEnabled, showBotOverlay, animateAvatar, null); + StatusDisplayOptions statusDisplayOptions) { + this.setupWithStatus(status, listener, statusDisplayOptions, null); } protected void setupWithStatus(StatusViewData.Concrete status, final StatusActionListener listener, - boolean mediaPreviewEnabled, - boolean showBotOverlay, - boolean animateAvatar, + StatusDisplayOptions statusDisplayOptions, @Nullable Object payloads) { if (payloads == null) { setDisplayName(status.getUserFullName(), status.getAccountEmojis()); setUsername(status.getNickname()); setCreatedAt(status.getCreatedAt()); setIsReply(status.getInReplyToId() != null); - setAvatar(status.getAvatar(), status.getRebloggedAvatar(), status.isBot(), showBotOverlay, animateAvatar); + setAvatar(status.getAvatar(), status.getRebloggedAvatar(), status.isBot(), statusDisplayOptions); setReblogged(status.isReblogged()); setFavourited(status.isFavourited()); setBookmarked(status.isBookmarked()); List attachments = status.getAttachments(); boolean sensitive = status.isSensitive(); - if (mediaPreviewEnabled && !hasAudioAttachment(attachments)) { - setMediaPreviews(attachments, sensitive, listener, status.isShowingContent()); + if (statusDisplayOptions.mediaPreviewEnabled() && !hasAudioAttachment(attachments)) { + setMediaPreviews(attachments, sensitive, listener, status.isShowingContent(), statusDisplayOptions.useBlurhash()); if (attachments.size() == 0) { hideSensitiveMediaWarning(); @@ -674,7 +698,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { } protected static boolean hasAudioAttachment(List attachments) { - for(Attachment attachment: attachments) { + for (Attachment attachment : attachments) { if (attachment.getType() == Attachment.Type.AUDIO) { return true; } @@ -747,7 +771,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { private static CharSequence getVisibilityDescription(Context context, Status.Visibility visibility) { - if(visibility == null) { + if (visibility == null) { return ""; } diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/StatusDetailedViewHolder.java b/app/src/main/java/com/keylesspalace/tusky/adapter/StatusDetailedViewHolder.java index b94b4cb7..771002ae 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/StatusDetailedViewHolder.java +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/StatusDetailedViewHolder.java @@ -16,6 +16,9 @@ import android.widget.LinearLayout; import android.widget.TextView; import android.widget.Toast; +import androidx.annotation.Nullable; +import androidx.recyclerview.widget.RecyclerView; + import com.bumptech.glide.Glide; import com.bumptech.glide.load.resource.bitmap.CenterCrop; import com.bumptech.glide.load.resource.bitmap.GranularRoundedCorners; @@ -25,14 +28,12 @@ import com.keylesspalace.tusky.entity.Status; import com.keylesspalace.tusky.interfaces.StatusActionListener; import com.keylesspalace.tusky.util.CustomURLSpan; import com.keylesspalace.tusky.util.LinkHelper; +import com.keylesspalace.tusky.util.StatusDisplayOptions; import com.keylesspalace.tusky.viewdata.StatusViewData; import java.text.DateFormat; import java.util.Date; -import androidx.annotation.Nullable; -import androidx.recyclerview.widget.RecyclerView; - class StatusDetailedViewHolder extends StatusBaseViewHolder { private TextView reblogs; private TextView favourites; @@ -44,8 +45,8 @@ class StatusDetailedViewHolder extends StatusBaseViewHolder { private TextView cardUrl; private View infoDivider; - StatusDetailedViewHolder(View view, boolean useAbsoluteTime) { - super(view, useAbsoluteTime); + StatusDetailedViewHolder(View view) { + super(view); reblogs = view.findViewById(R.id.status_reblogs); favourites = view.findViewById(R.id.status_favourites); cardView = view.findViewById(R.id.card_view); @@ -125,10 +126,11 @@ class StatusDetailedViewHolder extends StatusBaseViewHolder { } @Override - protected void setupWithStatus(final StatusViewData.Concrete status, final StatusActionListener listener, - boolean mediaPreviewEnabled, boolean showBotOverlay, boolean animateAvatar, + protected void setupWithStatus(final StatusViewData.Concrete status, + final StatusActionListener listener, + StatusDisplayOptions statusDisplayOptions, @Nullable Object payloads) { - super.setupWithStatus(status, listener, mediaPreviewEnabled, showBotOverlay, animateAvatar, payloads); + super.setupWithStatus(status, listener, statusDisplayOptions, payloads); if (payloads == null) { setReblogAndFavCount(status.getReblogsCount(), status.getFavouritesCount(), listener); diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/StatusViewHolder.java b/app/src/main/java/com/keylesspalace/tusky/adapter/StatusViewHolder.java index dba84da4..40800fa9 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/StatusViewHolder.java +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/StatusViewHolder.java @@ -22,14 +22,15 @@ import android.view.View; import android.widget.TextView; import android.widget.ToggleButton; +import androidx.annotation.Nullable; +import androidx.recyclerview.widget.RecyclerView; + import com.keylesspalace.tusky.R; import com.keylesspalace.tusky.interfaces.StatusActionListener; import com.keylesspalace.tusky.util.SmartLengthInputFilter; +import com.keylesspalace.tusky.util.StatusDisplayOptions; import com.keylesspalace.tusky.viewdata.StatusViewData; -import androidx.annotation.Nullable; -import androidx.recyclerview.widget.RecyclerView; - import at.connyduck.sparkbutton.helpers.Utils; public class StatusViewHolder extends StatusBaseViewHolder { @@ -39,8 +40,8 @@ public class StatusViewHolder extends StatusBaseViewHolder { private TextView statusInfo; private ToggleButton contentCollapseButton; - public StatusViewHolder(View itemView, boolean useAbsoluteTime) { - super(itemView, useAbsoluteTime); + public StatusViewHolder(View itemView) { + super(itemView); statusInfo = itemView.findViewById(R.id.status_info); contentCollapseButton = itemView.findViewById(R.id.button_toggle_content); } @@ -51,8 +52,9 @@ public class StatusViewHolder extends StatusBaseViewHolder { } @Override - protected void setupWithStatus(StatusViewData.Concrete status, final StatusActionListener listener, - boolean mediaPreviewEnabled, boolean showBotOverlay, boolean animateAvatar, + protected void setupWithStatus(StatusViewData.Concrete status, + final StatusActionListener listener, + StatusDisplayOptions statusDisplayOptions, @Nullable Object payloads) { if (payloads == null) { @@ -67,7 +69,7 @@ public class StatusViewHolder extends StatusBaseViewHolder { } } - super.setupWithStatus(status, listener, mediaPreviewEnabled, showBotOverlay, animateAvatar, payloads); + super.setupWithStatus(status, listener, statusDisplayOptions, payloads); } diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/ThreadAdapter.java b/app/src/main/java/com/keylesspalace/tusky/adapter/ThreadAdapter.java index 25376ccc..0143cb43 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/ThreadAdapter.java +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/ThreadAdapter.java @@ -15,15 +15,17 @@ package com.keylesspalace.tusky.adapter; -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.recyclerview.widget.RecyclerView; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.recyclerview.widget.RecyclerView; + import com.keylesspalace.tusky.R; import com.keylesspalace.tusky.interfaces.StatusActionListener; +import com.keylesspalace.tusky.util.StatusDisplayOptions; import com.keylesspalace.tusky.viewdata.StatusViewData; import java.util.ArrayList; @@ -34,20 +36,14 @@ public class ThreadAdapter extends RecyclerView.Adapter { private static final int VIEW_TYPE_STATUS_DETAILED = 1; private List statuses; + private StatusDisplayOptions statusDisplayOptions; private StatusActionListener statusActionListener; - private boolean mediaPreviewEnabled; - private boolean useAbsoluteTime; - private boolean showBotOverlay; - private boolean animateAvatar; private int detailedStatusPosition; - public ThreadAdapter(StatusActionListener listener) { + public ThreadAdapter(StatusDisplayOptions statusDisplayOptions, StatusActionListener listener) { + this.statusDisplayOptions = statusDisplayOptions; this.statusActionListener = listener; this.statuses = new ArrayList<>(); - mediaPreviewEnabled = true; - useAbsoluteTime = false; - showBotOverlay = true; - animateAvatar = false; detailedStatusPosition = RecyclerView.NO_POSITION; } @@ -59,12 +55,12 @@ public class ThreadAdapter extends RecyclerView.Adapter { case VIEW_TYPE_STATUS: { View view = LayoutInflater.from(parent.getContext()) .inflate(R.layout.item_status, parent, false); - return new StatusViewHolder(view, useAbsoluteTime); + return new StatusViewHolder(view); } case VIEW_TYPE_STATUS_DETAILED: { View view = LayoutInflater.from(parent.getContext()) .inflate(R.layout.item_status_detailed, parent, false); - return new StatusDetailedViewHolder(view, useAbsoluteTime); + return new StatusDetailedViewHolder(view); } } } @@ -74,10 +70,10 @@ public class ThreadAdapter extends RecyclerView.Adapter { StatusViewData.Concrete status = statuses.get(position); if (position == detailedStatusPosition) { StatusDetailedViewHolder holder = (StatusDetailedViewHolder) viewHolder; - holder.setupWithStatus(status, statusActionListener, mediaPreviewEnabled, showBotOverlay, animateAvatar); + holder.setupWithStatus(status, statusActionListener, statusDisplayOptions); } else { StatusViewHolder holder = (StatusViewHolder) viewHolder; - holder.setupWithStatus(status, statusActionListener, mediaPreviewEnabled, showBotOverlay, animateAvatar); + holder.setupWithStatus(status, statusActionListener, statusDisplayOptions); } } @@ -151,22 +147,6 @@ public class ThreadAdapter extends RecyclerView.Adapter { } } - public void setMediaPreviewEnabled(boolean enabled) { - mediaPreviewEnabled = enabled; - } - - public void setUseAbsoluteTime(boolean useAbsoluteTime) { - this.useAbsoluteTime = useAbsoluteTime; - } - - public void setShowBotOverlay(boolean showBotOverlay) { - this.showBotOverlay = showBotOverlay; - } - - public void setAnimateAvatar(boolean animateAvatar) { - this.animateAvatar = animateAvatar; - } - public void setDetailedStatusPosition(int position) { if (position != detailedStatusPosition && detailedStatusPosition != RecyclerView.NO_POSITION) { diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/TimelineAdapter.java b/app/src/main/java/com/keylesspalace/tusky/adapter/TimelineAdapter.java index 2fc90395..464d2bc6 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/TimelineAdapter.java +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/TimelineAdapter.java @@ -15,19 +15,21 @@ package com.keylesspalace.tusky.adapter; -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.recyclerview.widget.RecyclerView; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; -import java.util.List; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.recyclerview.widget.RecyclerView; import com.keylesspalace.tusky.R; import com.keylesspalace.tusky.interfaces.StatusActionListener; +import com.keylesspalace.tusky.util.StatusDisplayOptions; import com.keylesspalace.tusky.viewdata.StatusViewData; +import java.util.List; + public final class TimelineAdapter extends RecyclerView.Adapter { public interface AdapterDataSource { @@ -40,20 +42,29 @@ public final class TimelineAdapter extends RecyclerView.Adapter { private static final int VIEW_TYPE_PLACEHOLDER = 2; private final AdapterDataSource dataSource; + private StatusDisplayOptions statusDisplayOptions; private final StatusActionListener statusListener; - private boolean mediaPreviewEnabled; - private boolean useAbsoluteTime; - private boolean showBotOverlay; - private boolean animateAvatar; public TimelineAdapter(AdapterDataSource dataSource, + StatusDisplayOptions statusDisplayOptions, StatusActionListener statusListener) { this.dataSource = dataSource; + this.statusDisplayOptions = statusDisplayOptions; this.statusListener = statusListener; - mediaPreviewEnabled = true; - useAbsoluteTime = false; - showBotOverlay = true; - animateAvatar = false; + } + + public boolean getMediaPreviewEnabled() { + return statusDisplayOptions.mediaPreviewEnabled(); + } + + public void setMediaPreviewEnabled(boolean mediaPreviewEnabled) { + this.statusDisplayOptions = statusDisplayOptions.copy( + statusDisplayOptions.animateAvatars(), + mediaPreviewEnabled, + statusDisplayOptions.useAbsoluteTime(), + statusDisplayOptions.showBotOverlay(), + statusDisplayOptions.useBlurhash() + ); } @NonNull @@ -64,7 +75,7 @@ public final class TimelineAdapter extends RecyclerView.Adapter { case VIEW_TYPE_STATUS: { View view = LayoutInflater.from(viewGroup.getContext()) .inflate(R.layout.item_status, viewGroup, false); - return new StatusViewHolder(view, useAbsoluteTime); + return new StatusViewHolder(view); } case VIEW_TYPE_PLACEHOLDER: { View view = LayoutInflater.from(viewGroup.getContext()) @@ -76,16 +87,16 @@ public final class TimelineAdapter extends RecyclerView.Adapter { @Override public void onBindViewHolder(@NonNull RecyclerView.ViewHolder viewHolder, int position) { - bindViewHolder(viewHolder,position,null); + bindViewHolder(viewHolder, position, null); } @Override public void onBindViewHolder(@NonNull RecyclerView.ViewHolder viewHolder, int position, @NonNull List payloads) { - bindViewHolder(viewHolder,position,payloads); + bindViewHolder(viewHolder, position, payloads); } - private void bindViewHolder(@NonNull RecyclerView.ViewHolder viewHolder, int position, @Nullable List payloads){ + private void bindViewHolder(@NonNull RecyclerView.ViewHolder viewHolder, int position, @Nullable List payloads) { StatusViewData status = dataSource.getItemAt(position); if (status instanceof StatusViewData.Placeholder) { PlaceholderViewHolder holder = (PlaceholderViewHolder) viewHolder; @@ -94,12 +105,11 @@ public final class TimelineAdapter extends RecyclerView.Adapter { StatusViewHolder holder = (StatusViewHolder) viewHolder; holder.setupWithStatus((StatusViewData.Concrete) status, statusListener, - mediaPreviewEnabled, - showBotOverlay, - animateAvatar, + statusDisplayOptions, payloads != null && !payloads.isEmpty() ? payloads.get(0) : null); } } + @Override public int getItemCount() { return dataSource.getItemCount(); @@ -114,26 +124,6 @@ public final class TimelineAdapter extends RecyclerView.Adapter { } } - public void setMediaPreviewEnabled(boolean enabled) { - mediaPreviewEnabled = enabled; - } - - public void setUseAbsoluteTime(boolean useAbsoluteTime){ - this.useAbsoluteTime = useAbsoluteTime; - } - - public boolean getMediaPreviewEnabled() { - return mediaPreviewEnabled; - } - - public void setShowBotOverlay(boolean showBotOverlay) { - this.showBotOverlay = showBotOverlay; - } - - public void setAnimateAvatar(boolean animateAvatar) { - this.animateAvatar = animateAvatar; - } - @Override public long getItemId(int position) { return dataSource.getItemAt(position).getViewDataId(); diff --git a/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationAdapter.kt index 0b412029..6d6aee48 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationAdapter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationAdapter.kt @@ -12,20 +12,21 @@ import com.keylesspalace.tusky.R import com.keylesspalace.tusky.adapter.NetworkStateViewHolder import com.keylesspalace.tusky.interfaces.StatusActionListener import com.keylesspalace.tusky.util.NetworkState +import com.keylesspalace.tusky.util.StatusDisplayOptions -class ConversationAdapter(private val useAbsoluteTime: Boolean, - private val mediaPreviewEnabled: Boolean, - private val listener: StatusActionListener, - private val topLoadedCallback: () -> Unit, - private val retryCallback: () -> Unit) - : RecyclerView.Adapter() { +class ConversationAdapter( + private val statusDisplayOptions: StatusDisplayOptions, + private val listener: StatusActionListener, + private val topLoadedCallback: () -> Unit, + private val retryCallback: () -> Unit +) : RecyclerView.Adapter() { private var networkState: NetworkState? = null - private val differ: AsyncPagedListDiffer = AsyncPagedListDiffer(object: ListUpdateCallback { + private val differ: AsyncPagedListDiffer = AsyncPagedListDiffer(object : ListUpdateCallback { override fun onInserted(position: Int, count: Int) { notifyItemRangeInserted(position, count) - if(position == 0) { + if (position == 0) { topLoadedCallback() } } @@ -51,7 +52,8 @@ class ConversationAdapter(private val useAbsoluteTime: Boolean, val view = LayoutInflater.from(parent.context).inflate(viewType, parent, false) return when (viewType) { R.layout.item_network_state -> NetworkStateViewHolder(view, retryCallback) - R.layout.item_conversation -> ConversationViewHolder(view, listener, useAbsoluteTime, mediaPreviewEnabled) + R.layout.item_conversation -> ConversationViewHolder(view, statusDisplayOptions, + listener) else -> throw IllegalArgumentException("unknown view type $viewType") } } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationViewHolder.java b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationViewHolder.java index 34651c20..c759c06c 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationViewHolder.java +++ b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationViewHolder.java @@ -23,7 +23,6 @@ import android.widget.ImageView; import android.widget.TextView; import android.widget.ToggleButton; -import androidx.preference.PreferenceManager; import androidx.recyclerview.widget.RecyclerView; import com.keylesspalace.tusky.R; @@ -32,6 +31,7 @@ import com.keylesspalace.tusky.entity.Attachment; import com.keylesspalace.tusky.interfaces.StatusActionListener; import com.keylesspalace.tusky.util.ImageLoadingHelper; import com.keylesspalace.tusky.util.SmartLengthInputFilter; +import com.keylesspalace.tusky.util.StatusDisplayOptions; import com.keylesspalace.tusky.viewdata.PollViewDataKt; import java.util.List; @@ -44,15 +44,13 @@ public class ConversationViewHolder extends StatusBaseViewHolder { private ToggleButton contentCollapseButton; private ImageView[] avatars; + private StatusDisplayOptions statusDisplayOptions; private StatusActionListener listener; - private boolean mediaPreviewEnabled; - private boolean animateAvatars; ConversationViewHolder(View itemView, - StatusActionListener listener, - boolean useAbsoluteTime, - boolean mediaPreviewEnabled) { - super(itemView, useAbsoluteTime); + StatusDisplayOptions statusDisplayOptions, + StatusActionListener listener) { + super(itemView); conversationNameTextView = itemView.findViewById(R.id.conversation_name); contentCollapseButton = itemView.findViewById(R.id.button_toggle_content); avatars = new ImageView[]{ @@ -60,11 +58,10 @@ public class ConversationViewHolder extends StatusBaseViewHolder { itemView.findViewById(R.id.status_avatar_1), itemView.findViewById(R.id.status_avatar_2) }; + this.statusDisplayOptions = statusDisplayOptions; this.listener = listener; - this.mediaPreviewEnabled = mediaPreviewEnabled; - this.animateAvatars = PreferenceManager.getDefaultSharedPreferences(itemView.getContext()).getBoolean("animateGifAvatars", false); } @Override @@ -86,8 +83,9 @@ public class ConversationViewHolder extends StatusBaseViewHolder { setBookmarked(status.getBookmarked()); List attachments = status.getAttachments(); boolean sensitive = status.getSensitive(); - if(mediaPreviewEnabled && !hasAudioAttachment(attachments)) { - setMediaPreviews(attachments, sensitive, listener, status.getShowingHiddenContent()); + if (statusDisplayOptions.mediaPreviewEnabled() && !hasAudioAttachment(attachments)) { + setMediaPreviews(attachments, sensitive, listener, status.getShowingHiddenContent(), + statusDisplayOptions.useBlurhash()); if (attachments.size() == 0) { hideSensitiveMediaWarning(); @@ -118,11 +116,11 @@ public class ConversationViewHolder extends StatusBaseViewHolder { private void setConversationName(List accounts) { Context context = conversationNameTextView.getContext(); String conversationName = ""; - if(accounts.size() == 1) { + if (accounts.size() == 1) { conversationName = context.getString(R.string.conversation_1_recipients, accounts.get(0).getUsername()); - } else if(accounts.size() == 2) { + } 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){ + } else if (accounts.size() > 2) { conversationName = context.getString(R.string.conversation_more_recipients, accounts.get(0).getUsername(), accounts.get(1).getUsername(), accounts.size() - 2); } @@ -130,10 +128,11 @@ public class ConversationViewHolder extends StatusBaseViewHolder { } private void setAvatars(List accounts) { - for(int i=0; i < avatars.length; i++) { + for (int i = 0; i < avatars.length; i++) { ImageView avatarView = avatars[i]; - if(i < accounts.size()) { - ImageLoadingHelper.loadAvatar(accounts.get(i).getAvatar(), avatarView, avatarRadius48dp, animateAvatars); + if (i < accounts.size()) { + ImageLoadingHelper.loadAvatar(accounts.get(i).getAvatar(), avatarView, + avatarRadius48dp, statusDisplayOptions.animateAvatars()); avatarView.setVisibility(View.VISIBLE); } else { avatarView.setVisibility(View.GONE); diff --git a/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsFragment.kt index f192cae7..91f6550f 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsFragment.kt @@ -37,6 +37,7 @@ import com.keylesspalace.tusky.fragment.SFragment import com.keylesspalace.tusky.interfaces.ReselectableFragment import com.keylesspalace.tusky.interfaces.StatusActionListener import com.keylesspalace.tusky.util.NetworkState +import com.keylesspalace.tusky.util.StatusDisplayOptions import com.keylesspalace.tusky.util.ThemeUtils import com.keylesspalace.tusky.util.hide import kotlinx.android.synthetic.main.fragment_timeline.* @@ -62,15 +63,18 @@ class ConversationsFragment : SFragment(), StatusActionListener, Injectable, Res } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - val preferences = PreferenceManager.getDefaultSharedPreferences(view.context) - val useAbsoluteTime = preferences.getBoolean("absoluteTimeView", false) - val account = accountManager.activeAccount - val mediaPreviewEnabled = account?.mediaPreviewEnabled ?: true + val statusDisplayOptions = StatusDisplayOptions( + animateAvatars = preferences.getBoolean("animateGifAvatars", false), + mediaPreviewEnabled = accountManager.activeAccount?.mediaPreviewEnabled ?: true, + useAbsoluteTime = preferences.getBoolean("absoluteTimeView", false), + showBotOverlay = preferences.getBoolean("showBotOverlay", true), + useBlurhash = preferences.getBoolean("useBlurhash", true) + ) - adapter = ConversationAdapter(useAbsoluteTime, mediaPreviewEnabled, this, ::onTopLoaded, viewModel::retry) + adapter = ConversationAdapter(statusDisplayOptions, this, ::onTopLoaded, viewModel::retry) recyclerView.addItemDecoration(DividerItemDecoration(view.context, DividerItemDecoration.VERTICAL)) layoutManager = LinearLayoutManager(view.context) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/report/adapter/StatusViewHolder.kt b/app/src/main/java/com/keylesspalace/tusky/components/report/adapter/StatusViewHolder.kt index 60821972..40d5093f 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/report/adapter/StatusViewHolder.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/report/adapter/StatusViewHolder.kt @@ -31,12 +31,13 @@ import com.keylesspalace.tusky.viewdata.toViewData import kotlinx.android.synthetic.main.item_report_status.view.* import java.util.* -class StatusViewHolder(itemView: View, - private val useAbsoluteTime: Boolean, - private val mediaPreviewEnabled: Boolean, - private val viewState: StatusViewState, - private val adapterHandler: AdapterHandler, - private val getStatusForPosition: (Int) -> Status?) : RecyclerView.ViewHolder(itemView) { +class StatusViewHolder( + itemView: View, + private val statusDisplayOptions: StatusDisplayOptions, + private val viewState: StatusViewState, + private val adapterHandler: AdapterHandler, + private val getStatusForPosition: (Int) -> Status? +) : RecyclerView.ViewHolder(itemView) { private val mediaViewHeight = itemView.context.resources.getDimensionPixelSize(R.dimen.status_media_preview_height) private val statusViewHelper = StatusViewHelper(itemView) @@ -69,11 +70,11 @@ class StatusViewHolder(itemView: View, val sensitive = status.sensitive - statusViewHelper.setMediasPreview(mediaPreviewEnabled, status.attachments, sensitive, previewListener, - viewState.isMediaShow(status.id, status.sensitive), + statusViewHelper.setMediasPreview(statusDisplayOptions, status.attachments, + sensitive, previewListener, viewState.isMediaShow(status.id, status.sensitive), mediaViewHeight) - statusViewHelper.setupPollReadonly(status.poll.toViewData(), status.emojis, useAbsoluteTime) + statusViewHelper.setupPollReadonly(status.poll.toViewData(), status.emojis, statusDisplayOptions.useAbsoluteTime) setCreatedAt(status.createdAt) } @@ -124,7 +125,7 @@ class StatusViewHolder(itemView: View, } private fun setCreatedAt(createdAt: Date?) { - if (useAbsoluteTime) { + if (statusDisplayOptions.useAbsoluteTime) { itemView.timestampInfo.text = statusViewHelper.getAbsoluteTime(createdAt) } else { itemView.timestampInfo.text = if (createdAt != null) { diff --git a/app/src/main/java/com/keylesspalace/tusky/components/report/adapter/StatusesAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/report/adapter/StatusesAdapter.kt index c5ecea09..34817ca0 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/report/adapter/StatusesAdapter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/report/adapter/StatusesAdapter.kt @@ -23,12 +23,13 @@ import androidx.recyclerview.widget.RecyclerView import com.keylesspalace.tusky.R import com.keylesspalace.tusky.components.report.model.StatusViewState import com.keylesspalace.tusky.entity.Status +import com.keylesspalace.tusky.util.StatusDisplayOptions -class StatusesAdapter(private val useAbsoluteTime: Boolean, - private val mediaPreviewEnabled: Boolean, - private val statusViewState: StatusViewState, - private val adapterHandler: AdapterHandler) - : PagedListAdapter(STATUS_COMPARATOR) { +class StatusesAdapter( + private val statusDisplayOptions: StatusDisplayOptions, + private val statusViewState: StatusViewState, + private val adapterHandler: AdapterHandler +) : PagedListAdapter(STATUS_COMPARATOR) { private val statusForPosition: (Int) -> Status? = { position: Int -> if (position != RecyclerView.NO_POSITION) getItem(position) else null @@ -36,8 +37,10 @@ class StatusesAdapter(private val useAbsoluteTime: Boolean, override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { - return StatusViewHolder(LayoutInflater.from(parent.context).inflate(R.layout.item_report_status, parent, false), - useAbsoluteTime, mediaPreviewEnabled, statusViewState, adapterHandler, statusForPosition) + val view = LayoutInflater.from(parent.context) + .inflate(R.layout.item_report_status, parent, false) + return StatusViewHolder(view, statusDisplayOptions, statusViewState, adapterHandler, + statusForPosition) } override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { diff --git a/app/src/main/java/com/keylesspalace/tusky/components/report/fragments/ReportStatusesFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/report/fragments/ReportStatusesFragment.kt index 59d15517..cc6ea00f 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/report/fragments/ReportStatusesFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/report/fragments/ReportStatusesFragment.kt @@ -43,6 +43,7 @@ import com.keylesspalace.tusky.di.Injectable import com.keylesspalace.tusky.di.ViewModelFactory import com.keylesspalace.tusky.entity.Attachment import com.keylesspalace.tusky.entity.Status +import com.keylesspalace.tusky.util.StatusDisplayOptions import com.keylesspalace.tusky.util.ThemeUtils import com.keylesspalace.tusky.util.hide import com.keylesspalace.tusky.util.show @@ -119,14 +120,16 @@ class ReportStatusesFragment : Fragment(), Injectable, AdapterHandler { private fun initStatusesView() { val preferences = PreferenceManager.getDefaultSharedPreferences(requireContext()) + val statusDisplayOptions = StatusDisplayOptions( + animateAvatars = false, + mediaPreviewEnabled = accountManager.activeAccount?.mediaPreviewEnabled ?: true, + useAbsoluteTime = preferences.getBoolean("absoluteTimeView", false), + showBotOverlay = false, + useBlurhash = preferences.getBoolean("useBlurhash", true) + ) - val useAbsoluteTime = preferences.getBoolean("absoluteTimeView", false) - - val account = accountManager.activeAccount - val mediaPreviewEnabled = account?.mediaPreviewEnabled ?: true - - - adapter = StatusesAdapter(useAbsoluteTime, mediaPreviewEnabled, viewModel.statusViewState, this) + adapter = StatusesAdapter(statusDisplayOptions, + viewModel.statusViewState, this) recyclerView.addItemDecoration(DividerItemDecoration(requireContext(), DividerItemDecoration.VERTICAL)) layoutManager = LinearLayoutManager(requireContext()) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/search/adapter/SearchStatusesAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/search/adapter/SearchStatusesAdapter.kt index 0be27308..aaeb32ef 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/search/adapter/SearchStatusesAdapter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/search/adapter/SearchStatusesAdapter.kt @@ -24,28 +24,26 @@ import com.keylesspalace.tusky.R import com.keylesspalace.tusky.adapter.StatusViewHolder import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.interfaces.StatusActionListener +import com.keylesspalace.tusky.util.StatusDisplayOptions import com.keylesspalace.tusky.viewdata.StatusViewData -class SearchStatusesAdapter(private val useAbsoluteTime: Boolean, - private val mediaPreviewEnabled: Boolean, - private val showBotOverlay: Boolean, - private val animateAvatar: Boolean, - private val statusListener: StatusActionListener) - : PagedListAdapter, RecyclerView.ViewHolder>(STATUS_COMPARATOR) { +class SearchStatusesAdapter( + private val statusDisplayOptions: StatusDisplayOptions, + private val statusListener: StatusActionListener +) : PagedListAdapter, RecyclerView.ViewHolder>(STATUS_COMPARATOR) { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { val view = LayoutInflater.from(parent.context) .inflate(R.layout.item_status, parent, false) - return StatusViewHolder(view, useAbsoluteTime) + return StatusViewHolder(view) } override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { getItem(position)?.let { item -> (holder as? StatusViewHolder)?.setupWithStatus(item.second, statusListener, - mediaPreviewEnabled, showBotOverlay, animateAvatar) + statusDisplayOptions) } - } public override fun getItem(position: Int): Pair? { diff --git a/app/src/main/java/com/keylesspalace/tusky/components/search/fragments/SearchStatusesFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/search/fragments/SearchStatusesFragment.kt index 02d02c9c..8df8e6de 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/search/fragments/SearchStatusesFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/search/fragments/SearchStatusesFragment.kt @@ -52,6 +52,7 @@ import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.interfaces.AccountSelectionListener import com.keylesspalace.tusky.interfaces.StatusActionListener import com.keylesspalace.tusky.util.NetworkState +import com.keylesspalace.tusky.util.StatusDisplayOptions import com.keylesspalace.tusky.viewdata.AttachmentViewData import com.keylesspalace.tusky.viewdata.StatusViewData import com.uber.autodispose.android.lifecycle.AndroidLifecycleScopeProvider.from @@ -71,13 +72,17 @@ class SearchStatusesFragment : SearchFragment, *> { val preferences = PreferenceManager.getDefaultSharedPreferences(searchRecyclerView.context) - val useAbsoluteTime = preferences.getBoolean("absoluteTimeView", false) - val showBotOverlay = preferences.getBoolean("showBotOverlay", true) - val animateAvatar = preferences.getBoolean("animateGifAvatars", false) + val statusDisplayOptions = StatusDisplayOptions( + animateAvatars = preferences.getBoolean("animateGifAvatars", false), + mediaPreviewEnabled = viewModel.mediaPreviewEnabled, + useAbsoluteTime = preferences.getBoolean("absoluteTimeView", false), + showBotOverlay = preferences.getBoolean("showBotOverlay", true), + useBlurhash = preferences.getBoolean("useBlurhash", true) + ) searchRecyclerView.addItemDecoration(DividerItemDecoration(searchRecyclerView.context, DividerItemDecoration.VERTICAL)) searchRecyclerView.layoutManager = LinearLayoutManager(searchRecyclerView.context) - return SearchStatusesAdapter(useAbsoluteTime, viewModel.mediaPreviewEnabled, showBotOverlay, animateAvatar, this) + return SearchStatusesAdapter(statusDisplayOptions, this) } diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/Attachment.kt b/app/src/main/java/com/keylesspalace/tusky/entity/Attachment.kt index dc3732bc..3517ff09 100644 --- a/app/src/main/java/com/keylesspalace/tusky/entity/Attachment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/entity/Attachment.kt @@ -31,7 +31,8 @@ data class Attachment( @SerializedName("preview_url") val previewUrl: String, val meta: MetaData?, val type: Type, - val description: String? + val description: String?, + val blurhash: String? ) : Parcelable { @JsonAdapter(MediaTypeDeserializer::class) diff --git a/app/src/main/java/com/keylesspalace/tusky/fragment/NotificationsFragment.java b/app/src/main/java/com/keylesspalace/tusky/fragment/NotificationsFragment.java index 4d5afd1e..9db98bce 100644 --- a/app/src/main/java/com/keylesspalace/tusky/fragment/NotificationsFragment.java +++ b/app/src/main/java/com/keylesspalace/tusky/fragment/NotificationsFragment.java @@ -75,6 +75,7 @@ import com.keylesspalace.tusky.util.ListStatusAccessibilityDelegate; import com.keylesspalace.tusky.util.ListUtils; import com.keylesspalace.tusky.util.NotificationTypeConverterKt; import com.keylesspalace.tusky.util.PairedList; +import com.keylesspalace.tusky.util.StatusDisplayOptions; import com.keylesspalace.tusky.util.ThemeUtils; import com.keylesspalace.tusky.util.ViewDataUtils; import com.keylesspalace.tusky.view.BackgroundMessageView; @@ -237,18 +238,18 @@ public class NotificationsFragment extends SFragment implements recyclerView.addItemDecoration(new DividerItemDecoration(context, DividerItemDecoration.VERTICAL)); + StatusDisplayOptions statusDisplayOptions = new StatusDisplayOptions( + preferences.getBoolean("animateGifAvatars", false), + accountManager.getActiveAccount().getMediaPreviewEnabled(), + preferences.getBoolean("absoluteTimeView", false), + preferences.getBoolean("showBotOverlay", true), + preferences.getBoolean("useBlurhash", true) + ); + adapter = new NotificationsAdapter(accountManager.getActiveAccount().getAccountId(), - dataSource, this, this); + dataSource, statusDisplayOptions, this, this); alwaysShowSensitiveMedia = accountManager.getActiveAccount().getAlwaysShowSensitiveMedia(); alwaysOpenSpoiler = accountManager.getActiveAccount().getAlwaysOpenSpoiler(); - boolean mediaPreviewEnabled = accountManager.getActiveAccount().getMediaPreviewEnabled(); - adapter.setMediaPreviewEnabled(mediaPreviewEnabled); - boolean useAbsoluteTime = preferences.getBoolean("absoluteTimeView", false); - adapter.setUseAbsoluteTime(useAbsoluteTime); - boolean showBotOverlay = preferences.getBoolean("showBotOverlay", true); - adapter.setShowBotOverlay(showBotOverlay); - boolean animateAvatar = preferences.getBoolean("animateGifAvatars", false); - adapter.setAnimateAvatar(animateAvatar); recyclerView.setAdapter(adapter); topLoading = false; diff --git a/app/src/main/java/com/keylesspalace/tusky/fragment/TimelineFragment.java b/app/src/main/java/com/keylesspalace/tusky/fragment/TimelineFragment.java index b3c42f87..62760d51 100644 --- a/app/src/main/java/com/keylesspalace/tusky/fragment/TimelineFragment.java +++ b/app/src/main/java/com/keylesspalace/tusky/fragment/TimelineFragment.java @@ -77,6 +77,7 @@ import com.keylesspalace.tusky.util.LinkHelper; import com.keylesspalace.tusky.util.ListStatusAccessibilityDelegate; import com.keylesspalace.tusky.util.ListUtils; import com.keylesspalace.tusky.util.PairedList; +import com.keylesspalace.tusky.util.StatusDisplayOptions; import com.keylesspalace.tusky.util.StringUtils; import com.keylesspalace.tusky.util.ThemeUtils; import com.keylesspalace.tusky.util.ViewDataUtils; @@ -219,7 +220,15 @@ public class TimelineFragment extends SFragment implements hashtagOrId = arguments.getString(HASHTAG_OR_ID_ARG); } - adapter = new TimelineAdapter(dataSource, this); + SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(getActivity()); + StatusDisplayOptions statusDisplayOptions = new StatusDisplayOptions( + preferences.getBoolean("animateGifAvatars", false), + accountManager.getActiveAccount().getMediaPreviewEnabled(), + preferences.getBoolean("absoluteTimeView", false), + preferences.getBoolean("showBotOverlay", true), + preferences.getBoolean("useBlurhash", true) + ); + adapter = new TimelineAdapter(dataSource, statusDisplayOptions, this); isSwipeToRefreshEnabled = arguments.getBoolean(ARG_ENABLE_SWIPE_TO_REFRESH, true); @@ -341,18 +350,10 @@ public class TimelineFragment extends SFragment implements } private void setupTimelinePreferences() { - SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(getActivity()); alwaysShowSensitiveMedia = accountManager.getActiveAccount().getAlwaysShowSensitiveMedia(); alwaysOpenSpoiler = accountManager.getActiveAccount().getAlwaysOpenSpoiler(); - boolean mediaPreviewEnabled = accountManager.getActiveAccount().getMediaPreviewEnabled(); - adapter.setMediaPreviewEnabled(mediaPreviewEnabled); - boolean useAbsoluteTime = preferences.getBoolean("absoluteTimeView", false); - adapter.setUseAbsoluteTime(useAbsoluteTime); - boolean showBotOverlay = preferences.getBoolean("showBotOverlay", true); - adapter.setShowBotOverlay(showBotOverlay); - boolean animateAvatar = preferences.getBoolean("animateGifAvatars", false); - adapter.setAnimateAvatar(animateAvatar); + SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(getContext()); boolean filter = preferences.getBoolean("tabFilterHomeReplies", true); filterRemoveReplies = kind == Kind.HOME && !filter; diff --git a/app/src/main/java/com/keylesspalace/tusky/fragment/ViewThreadFragment.java b/app/src/main/java/com/keylesspalace/tusky/fragment/ViewThreadFragment.java index bd8494a7..9dd9c86f 100644 --- a/app/src/main/java/com/keylesspalace/tusky/fragment/ViewThreadFragment.java +++ b/app/src/main/java/com/keylesspalace/tusky/fragment/ViewThreadFragment.java @@ -60,6 +60,7 @@ import com.keylesspalace.tusky.interfaces.StatusActionListener; import com.keylesspalace.tusky.network.MastodonApi; import com.keylesspalace.tusky.util.ListStatusAccessibilityDelegate; import com.keylesspalace.tusky.util.PairedList; +import com.keylesspalace.tusky.util.StatusDisplayOptions; import com.keylesspalace.tusky.util.ThemeUtils; import com.keylesspalace.tusky.util.ViewDataUtils; import com.keylesspalace.tusky.view.ConversationLineItemDecoration; @@ -123,8 +124,16 @@ public final class ViewThreadFragment extends SFragment implements super.onCreate(savedInstanceState); thisThreadsStatusId = getArguments().getString("id"); - - adapter = new ThreadAdapter(this); + SharedPreferences preferences = + PreferenceManager.getDefaultSharedPreferences(getActivity()); + StatusDisplayOptions statusDisplayOptions = new StatusDisplayOptions( + preferences.getBoolean("animateGifAvatars", false), + accountManager.getActiveAccount().getMediaPreviewEnabled(), + preferences.getBoolean("absoluteTimeView", false), + preferences.getBoolean("showBotOverlay", true), + preferences.getBoolean("useBlurhash", true) + ); + adapter = new ThreadAdapter(statusDisplayOptions, this); } @Override @@ -150,18 +159,8 @@ public final class ViewThreadFragment extends SFragment implements recyclerView.addItemDecoration(divider); recyclerView.addItemDecoration(new ConversationLineItemDecoration(context)); - SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences( - getActivity()); alwaysShowSensitiveMedia = accountManager.getActiveAccount().getAlwaysShowSensitiveMedia(); alwaysOpenSpoiler = accountManager.getActiveAccount().getAlwaysOpenSpoiler(); - boolean mediaPreviewEnabled = accountManager.getActiveAccount().getMediaPreviewEnabled(); - adapter.setMediaPreviewEnabled(mediaPreviewEnabled); - boolean useAbsoluteTime = preferences.getBoolean("absoluteTimeView", false); - adapter.setUseAbsoluteTime(useAbsoluteTime); - boolean animateAvatars = preferences.getBoolean("animateGifAvatars", false); - adapter.setAnimateAvatar(animateAvatars); - boolean showBotIndicator = preferences.getBoolean("showBotOverlay", true); - adapter.setShowBotOverlay(showBotIndicator); reloadFilters(false); recyclerView.setAdapter(adapter); diff --git a/app/src/main/java/com/keylesspalace/tusky/util/BlurHashDecoder.kt b/app/src/main/java/com/keylesspalace/tusky/util/BlurHashDecoder.kt new file mode 100644 index 00000000..3429dc24 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/util/BlurHashDecoder.kt @@ -0,0 +1,130 @@ +/** + * Blurhash implementation from blurhash project: + * https://github.com/woltapp/blurhash + * Minor modifications by charlag + */ + +package com.keylesspalace.tusky.util + +import android.graphics.Bitmap +import android.graphics.Color +import kotlin.math.PI +import kotlin.math.cos +import kotlin.math.pow +import kotlin.math.withSign + +object BlurHashDecoder { + + fun decode(blurHash: String?, width: Int, height: Int, punch: Float = 1f): Bitmap? { + require(width > 0) { "Width must be greater than zero" } + require(height > 0) { "height must be greater than zero" } + if (blurHash == null || blurHash.length < 6) { + return null + } + val numCompEnc = decode83(blurHash, 0, 1) + val numCompX = (numCompEnc % 9) + 1 + val numCompY = (numCompEnc / 9) + 1 + if (blurHash.length != 4 + 2 * numCompX * numCompY) { + return null + } + val maxAcEnc = decode83(blurHash, 1, 2) + val maxAc = (maxAcEnc + 1) / 166f + val colors = Array(numCompX * numCompY) { i -> + if (i == 0) { + val colorEnc = decode83(blurHash, 2, 6) + decodeDc(colorEnc) + } else { + val from = 4 + i * 2 + val colorEnc = decode83(blurHash, from, from + 2) + decodeAc(colorEnc, maxAc * punch) + } + } + return composeBitmap(width, height, numCompX, numCompY, colors) + } + + private fun decode83(str: String, from: Int = 0, to: Int = str.length): Int { + var result = 0 + for (i in from until to) { + val index = charMap[str[i]] ?: -1 + if (index != -1) { + result = result * 83 + index + } + } + return result + } + + private fun decodeDc(colorEnc: Int): FloatArray { + val r = colorEnc shr 16 + val g = (colorEnc shr 8) and 255 + val b = colorEnc and 255 + return floatArrayOf(srgbToLinear(r), srgbToLinear(g), srgbToLinear(b)) + } + + private fun srgbToLinear(colorEnc: Int): Float { + val v = colorEnc / 255f + return if (v <= 0.04045f) { + (v / 12.92f) + } else { + ((v + 0.055f) / 1.055f).pow(2.4f) + } + } + + private fun decodeAc(value: Int, maxAc: Float): FloatArray { + val r = value / (19 * 19) + val g = (value / 19) % 19 + val b = value % 19 + return floatArrayOf( + signedPow2((r - 9) / 9.0f) * maxAc, + signedPow2((g - 9) / 9.0f) * maxAc, + signedPow2((b - 9) / 9.0f) * maxAc + ) + } + + private fun signedPow2(value: Float) = value.pow(2f).withSign(value) + + private fun composeBitmap( + width: Int, height: Int, + numCompX: Int, numCompY: Int, + colors: Array + ): Bitmap { + val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888) + for (y in 0 until height) { + for (x in 0 until width) { + var r = 0f + var g = 0f + var b = 0f + for (j in 0 until numCompY) { + for (i in 0 until numCompX) { + val basis = (cos(PI * x * i / width) * cos(PI * y * j / height)).toFloat() + val color = colors[j * numCompX + i] + r += color[0] * basis + g += color[1] * basis + b += color[2] * basis + } + } + bitmap.setPixel(x, y, Color.rgb(linearToSrgb(r), linearToSrgb(g), linearToSrgb(b))) + } + } + return bitmap + } + + private fun linearToSrgb(value: Float): Int { + val v = value.coerceIn(0f, 1f) + return if (v <= 0.0031308f) { + (v * 12.92f * 255f + 0.5f).toInt() + } else { + ((1.055f * v.pow(1 / 2.4f) - 0.055f) * 255 + 0.5f).toInt() + } + } + + private val charMap = listOf( + '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'A', 'B', 'C', 'D', 'E', 'F', 'G', + 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', + 'Y', 'Z', 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o', + 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z', '#', '$', '%', '*', '+', ',', + '-', '.', ':', ';', '=', '?', '@', '[', ']', '^', '_', '{', '|', '}', '~' + ) + .mapIndexed { i, c -> c to i } + .toMap() + +} diff --git a/app/src/main/java/com/keylesspalace/tusky/util/ImageLoadingHelper.kt b/app/src/main/java/com/keylesspalace/tusky/util/ImageLoadingHelper.kt index 1ab3e374..9daf16f8 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/ImageLoadingHelper.kt +++ b/app/src/main/java/com/keylesspalace/tusky/util/ImageLoadingHelper.kt @@ -2,6 +2,8 @@ package com.keylesspalace.tusky.util +import android.content.Context +import android.graphics.drawable.BitmapDrawable import android.widget.ImageView import androidx.annotation.Px import com.bumptech.glide.Glide @@ -14,7 +16,7 @@ private val centerCropTransformation = CenterCrop() fun loadAvatar(url: String?, imageView: ImageView, @Px radius: Int, animate: Boolean) { - if(url.isNullOrBlank()) { + if (url.isNullOrBlank()) { Glide.with(imageView) .load(R.drawable.avatar_default) .into(imageView) @@ -42,4 +44,8 @@ fun loadAvatar(url: String?, imageView: ImageView, @Px radius: Int, animate: Boo } } +} + +fun decodeBlurHash(context: Context, blurhash: String): BitmapDrawable { + return BitmapDrawable(context.resources, BlurHashDecoder.decode(blurhash, 32, 32, 1f)) } \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/util/StatusDisplayOptions.kt b/app/src/main/java/com/keylesspalace/tusky/util/StatusDisplayOptions.kt new file mode 100644 index 00000000..143af1f8 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/util/StatusDisplayOptions.kt @@ -0,0 +1,14 @@ +package com.keylesspalace.tusky.util + +data class StatusDisplayOptions( + @get:JvmName("animateAvatars") + val animateAvatars: Boolean, + @get:JvmName("mediaPreviewEnabled") + val mediaPreviewEnabled: Boolean, + @get:JvmName("useAbsoluteTime") + val useAbsoluteTime: Boolean, + @get:JvmName("showBotOverlay") + val showBotOverlay: Boolean, + @get:JvmName("useBlurhash") + val useBlurhash: Boolean +) \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/util/StatusViewHelper.kt b/app/src/main/java/com/keylesspalace/tusky/util/StatusViewHelper.kt index 85e89802..8377d73e 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/StatusViewHelper.kt +++ b/app/src/main/java/com/keylesspalace/tusky/util/StatusViewHelper.kt @@ -16,6 +16,7 @@ package com.keylesspalace.tusky.util import android.content.Context +import android.graphics.drawable.ColorDrawable import android.text.InputFilter import android.text.TextUtils import android.view.View @@ -47,7 +48,7 @@ class StatusViewHelper(private val itemView: View) { private val longSdf = SimpleDateFormat("MM/dd HH:mm:ss", Locale.getDefault()) fun setMediasPreview( - mediaPreviewEnabled: Boolean, + statusDisplayOptions: StatusDisplayOptions, attachments: List, sensitive: Boolean, previewListener: MediaPreviewListener, @@ -70,7 +71,7 @@ class StatusViewHelper(private val itemView: View) { val sensitiveMediaWarning = itemView.findViewById(R.id.status_sensitive_media_warning) val sensitiveMediaShow = itemView.findViewById(R.id.status_sensitive_media_button) val mediaLabel = itemView.findViewById(R.id.status_media_label) - if (mediaPreviewEnabled) { + if (statusDisplayOptions.mediaPreviewEnabled) { // Hide the unused label. mediaLabel.visibility = View.GONE } else { @@ -86,13 +87,15 @@ class StatusViewHelper(private val itemView: View) { } - val mediaPreviewUnloadedId = ThemeUtils.getDrawableId(context, R.attr.media_preview_unloaded_drawable, android.R.color.black) + val mediaPreviewUnloaded = ThemeUtils.getDrawable(context, + R.attr.media_preview_unloaded_drawable, android.R.color.black) val n = min(attachments.size, Status.MAX_MEDIA_ATTACHMENTS) for (i in 0 until n) { - val previewUrl = attachments[i].previewUrl - val description = attachments[i].description + val attachment = attachments[i] + val previewUrl = attachment.previewUrl + val description = attachment.description if (TextUtils.isEmpty(description)) { mediaPreviews[i].contentDescription = context.getString(R.string.action_view_media) @@ -104,35 +107,49 @@ class StatusViewHelper(private val itemView: View) { if (TextUtils.isEmpty(previewUrl)) { Glide.with(mediaPreviews[i]) - .load(mediaPreviewUnloadedId) + .load(mediaPreviewUnloaded) .centerInside() .into(mediaPreviews[i]) } else { - val meta = attachments[i].meta + val placeholder = if (attachment.blurhash != null) + decodeBlurHash(context, attachment.blurhash) + else mediaPreviewUnloaded + val meta = attachment.meta val focus = meta?.focus + if (showingContent) { + if (focus != null) { // If there is a focal point for this attachment: + mediaPreviews[i].setFocalPoint(focus) - if (focus != null) { // If there is a focal point for this attachment: - mediaPreviews[i].setFocalPoint(focus) + Glide.with(mediaPreviews[i]) + .load(previewUrl) + .placeholder(placeholder) + .centerInside() + .addListener(mediaPreviews[i]) + .into(mediaPreviews[i]) + } else { + mediaPreviews[i].removeFocalPoint() - Glide.with(mediaPreviews[i]) - .load(previewUrl) - .placeholder(mediaPreviewUnloadedId) - .centerInside() - .addListener(mediaPreviews[i]) - .into(mediaPreviews[i]) + Glide.with(mediaPreviews[i]) + .load(previewUrl) + .placeholder(placeholder) + .centerInside() + .into(mediaPreviews[i]) + } } else { mediaPreviews[i].removeFocalPoint() - - Glide.with(mediaPreviews[i]) - .load(previewUrl) - .placeholder(mediaPreviewUnloadedId) - .centerInside() - .into(mediaPreviews[i]) + if (statusDisplayOptions.useBlurhash && attachment.blurhash != null) { + val blurhashBitmap = decodeBlurHash(context, attachment.blurhash) + mediaPreviews[i].setImageDrawable(blurhashBitmap) + } else { + mediaPreviews[i].setImageDrawable(ColorDrawable(ThemeUtils.getColor( + context, R.attr.sensitive_media_warning_background_color))) + } } } - val type = attachments[i].type - if ((type === Attachment.Type.VIDEO) or (type === Attachment.Type.GIFV)) { + val type = attachment.type + if (showingContent + && (type === Attachment.Type.VIDEO) or (type === Attachment.Type.GIFV)) { mediaOverlays[i].visibility = View.VISIBLE } else { mediaOverlays[i].visibility = View.GONE @@ -158,13 +175,9 @@ class StatusViewHelper(private val itemView: View) { } else { val hiddenContentText: String = if (sensitive) { - context.getString(R.string.status_sensitive_media_template, - context.getString(R.string.status_sensitive_media_title), - context.getString(R.string.status_sensitive_media_directions)) + context.getString(R.string.status_sensitive_media_title) } else { - context.getString(R.string.status_sensitive_media_template, - context.getString(R.string.status_media_hidden_title), - context.getString(R.string.status_sensitive_media_directions)) + context.getString(R.string.status_media_hidden_title) } sensitiveMediaWarning.text = HtmlUtils.fromHtml(hiddenContentText) @@ -175,11 +188,15 @@ class StatusViewHelper(private val itemView: View) { previewListener.onContentHiddenChange(false) v.visibility = View.GONE sensitiveMediaWarning.visibility = View.VISIBLE + setMediasPreview(statusDisplayOptions, attachments, sensitive, previewListener, + false, mediaPreviewHeight) } sensitiveMediaWarning.setOnClickListener { v -> previewListener.onContentHiddenChange(true) v.visibility = View.GONE sensitiveMediaShow.visibility = View.VISIBLE + setMediasPreview(statusDisplayOptions, attachments, sensitive, previewListener, + true, mediaPreviewHeight) } } diff --git a/app/src/main/java/com/keylesspalace/tusky/view/MediaPreviewImageView.kt b/app/src/main/java/com/keylesspalace/tusky/view/MediaPreviewImageView.kt index 7823e07e..42bfc276 100644 --- a/app/src/main/java/com/keylesspalace/tusky/view/MediaPreviewImageView.kt +++ b/app/src/main/java/com/keylesspalace/tusky/view/MediaPreviewImageView.kt @@ -50,7 +50,7 @@ defStyleAttr: Int = 0 /** * Set the focal point for this view. */ - fun setFocalPoint(focus: Attachment.Focus) { + fun setFocalPoint(focus: Attachment.Focus?) { this.focus = focus super.setScaleType(ScaleType.MATRIX) diff --git a/app/src/main/res/drawable/media_warning_bg.xml b/app/src/main/res/drawable/media_warning_bg.xml new file mode 100644 index 00000000..8921a225 --- /dev/null +++ b/app/src/main/res/drawable/media_warning_bg.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/item_report_status.xml b/app/src/main/res/layout/item_report_status.xml index 85914aa9..7b4c4bd1 100644 --- a/app/src/main/res/layout/item_report_status.xml +++ b/app/src/main/res/layout/item_report_status.xml @@ -93,7 +93,7 @@ app:layout_constraintEnd_toStartOf="@id/barrierEnd" app:layout_constraintStart_toStartOf="@id/guideBegin" app:layout_constraintTop_toBottomOf="@id/buttonToggleContent" - tools:visibility="gone"> + tools:visibility="visible"> + app:srcCompat="@drawable/ic_eye_24dp" + tools:visibility="visible" /> + tools:visibility="visible"> + app:layout_constraintTop_toTopOf="parent" + tools:text="@string/status_sensitive_media_title" + tools:visibility="visible" /> + app:layout_constraintTop_toTopOf="parent" + tools:text="@string/status_sensitive_media_title" + tools:visibility="visible" /> @drawable/favourite_active_dark @drawable/favourite_inactive_dark @drawable/toggle_small - @color/color_background_dark + #80000000 @drawable/media_preview_unloaded_dark @drawable/status_divider_dark @drawable/conversation_thread_line_dark diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 1ede742d..6d6dab14 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -227,6 +227,7 @@ Language Show indicator for bots Animate GIF avatars + Show colorful gradients for hidden media Timeline filtering Tabs diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml index 8251dfd6..8a73ad68 100644 --- a/app/src/main/res/values/styles.xml +++ b/app/src/main/res/values/styles.xml @@ -88,9 +88,7 @@ @drawable/favourite_active_light @drawable/favourite_inactive_light @drawable/toggle_small_light - - @color/sensitive_media_warning_background_light - + #80B0B0B0 @drawable/media_preview_unloaded_light @drawable/status_divider_light @drawable/conversation_thread_line_light @@ -189,7 +187,7 @@ @color/toolbar_background_black @color/window_background_black - @color/color_background_black + #80000000 @color/color_background_black @color/color_background_black diff --git a/app/src/main/res/xml/preferences.xml b/app/src/main/res/xml/preferences.xml index b565d4a9..6dc1faa9 100644 --- a/app/src/main/res/xml/preferences.xml +++ b/app/src/main/res/xml/preferences.xml @@ -60,6 +60,12 @@ android:title="@string/pref_title_animate_gif_avatars" app:singleLineTitle="false" /> + +