From 61a45ae3769d98d65bd59847e43de7f0b5761266 Mon Sep 17 00:00:00 2001 From: Konrad Pozniak Date: Mon, 2 Jan 2023 14:09:18 +0100 Subject: [PATCH] show status edits (#3049) * show status edits part 1 * show status edits part 2 - load status edits * fix code formatting * add dialog to show status edits * small improvements * use ALIGN_CENTER to position status visibility icon when possible * rename status_timestamp_info view to status_meta_info * make dateFormat static * remove commented-out code * move edits to dedicated fragment --- .../tusky/adapter/NotificationsAdapter.java | 2 +- .../tusky/adapter/PollAdapter.kt | 9 +- .../tusky/adapter/StatusBaseViewHolder.java | 25 ++- .../adapter/StatusDetailedViewHolder.java | 131 ++++++++----- .../conversation/ConversationViewHolder.java | 4 +- .../viewthread/ViewThreadFragment.kt | 14 ++ .../viewthread/ViewThreadViewModel.kt | 4 +- .../viewthread/edits/ViewEditsAdapter.kt | 185 ++++++++++++++++++ .../viewthread/edits/ViewEditsFragment.kt | 152 ++++++++++++++ .../viewthread/edits/ViewEditsViewModel.kt | 63 ++++++ .../tusky/di/FragmentBuildersModule.kt | 4 + .../tusky/di/ViewModelFactory.kt | 6 + .../keylesspalace/tusky/entity/StatusEdit.kt | 15 ++ .../interfaces/StatusActionListener.java | 2 + .../tusky/network/MastodonApi.kt | 6 + .../tusky/viewdata/StatusViewData.kt | 1 - .../layout-sw640dp/fragment_view_thread.xml | 1 - .../main/res/layout/fragment_view_thread.xml | 1 - app/src/main/res/layout/item_conversation.xml | 6 +- app/src/main/res/layout/item_poll.xml | 2 + app/src/main/res/layout/item_status.xml | 6 +- .../main/res/layout/item_status_detailed.xml | 7 +- app/src/main/res/layout/item_status_edit.xml | 148 ++++++++++++++ .../res/layout/item_status_notification.xml | 4 +- app/src/main/res/values/donottranslate.xml | 2 +- app/src/main/res/values/strings.xml | 6 + 26 files changed, 731 insertions(+), 75 deletions(-) create mode 100644 app/src/main/java/com/keylesspalace/tusky/components/viewthread/edits/ViewEditsAdapter.kt create mode 100644 app/src/main/java/com/keylesspalace/tusky/components/viewthread/edits/ViewEditsFragment.kt create mode 100644 app/src/main/java/com/keylesspalace/tusky/components/viewthread/edits/ViewEditsViewModel.kt create mode 100644 app/src/main/java/com/keylesspalace/tusky/entity/StatusEdit.kt create mode 100644 app/src/main/res/layout/item_status_edit.xml 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 e7c6eeee..98975543 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/NotificationsAdapter.java +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/NotificationsAdapter.java @@ -436,7 +436,7 @@ public class NotificationsAdapter extends RecyclerView.Adapter { statusNameBar = itemView.findViewById(R.id.status_name_bar); displayName = itemView.findViewById(R.id.status_display_name); username = itemView.findViewById(R.id.status_username); - timestampInfo = itemView.findViewById(R.id.status_timestamp_info); + timestampInfo = itemView.findViewById(R.id.status_meta_info); statusContent = itemView.findViewById(R.id.notification_content); statusAvatar = itemView.findViewById(R.id.notification_status_avatar); notificationAvatar = itemView.findViewById(R.id.notification_notification_avatar); diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/PollAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/adapter/PollAdapter.kt index 6d70d0e1..596c9432 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/PollAdapter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/PollAdapter.kt @@ -38,7 +38,9 @@ class PollAdapter : RecyclerView.Adapter>() { private var emojis: List = emptyList() private var resultClickListener: View.OnClickListener? = null private var animateEmojis = false + private var enabled = true + @JvmOverloads fun setup( options: List, voteCount: Int, @@ -46,7 +48,8 @@ class PollAdapter : RecyclerView.Adapter>() { emojis: List, mode: Int, resultClickListener: View.OnClickListener?, - animateEmojis: Boolean + animateEmojis: Boolean, + enabled: Boolean = true ) { this.pollOptions = options this.voteCount = voteCount @@ -55,6 +58,7 @@ class PollAdapter : RecyclerView.Adapter>() { this.mode = mode this.resultClickListener = resultClickListener this.animateEmojis = animateEmojis + this.enabled = enabled notifyDataSetChanged() } @@ -82,6 +86,9 @@ class PollAdapter : RecyclerView.Adapter>() { radioButton.visible(mode == SINGLE) checkBox.visible(mode == MULTIPLE) + radioButton.isEnabled = enabled + checkBox.isEnabled = enabled + when (mode) { RESULT -> { val percent = calculatePercent(option.votesCount, votersCount, voteCount) 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 2aa1316e..5ce8954a 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/StatusBaseViewHolder.java +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/StatusBaseViewHolder.java @@ -94,7 +94,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { private ImageView avatarInset; public ImageView avatar; - public TextView timestampInfo; + public TextView metaInfo; public TextView content; public TextView contentWarningDescription; @@ -123,7 +123,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { super(itemView); displayName = itemView.findViewById(R.id.status_display_name); username = itemView.findViewById(R.id.status_username); - timestampInfo = itemView.findViewById(R.id.status_timestamp_info); + 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); @@ -310,7 +310,12 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { } - protected void setCreatedAt(Date createdAt, Date editedAt, StatusDisplayOptions statusDisplayOptions) { + 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); @@ -320,15 +325,15 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { } else { long then = createdAt.getTime(); long now = System.currentTimeMillis(); - String readout = TimestampUtils.getRelativeTimeSpanString(timestampInfo.getContext(), then, now); + String readout = TimestampUtils.getRelativeTimeSpanString(metaInfo.getContext(), then, now); timestampText = readout; } } if (editedAt != null) { - timestampText = timestampInfo.getContext().getString(R.string.post_timestamp_with_edited_indicator, timestampText); + timestampText = metaInfo.getContext().getString(R.string.post_timestamp_with_edited_indicator, timestampText); } - timestampInfo.setText(timestampText); + metaInfo.setText(timestampText); } private CharSequence getCreatedAtDescription(Date createdAt, @@ -715,7 +720,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { Status actionable = status.getActionable(); setDisplayName(actionable.getAccount().getName(), actionable.getAccount().getEmojis(), statusDisplayOptions); setUsername(status.getUsername()); - setCreatedAt(actionable.getCreatedAt(), actionable.getEditedAt(), statusDisplayOptions); + setMetaData(status, statusDisplayOptions, listener); setIsReply(actionable.getInReplyToId() != null); setReplyCount(actionable.getRepliesCount()); setAvatar(actionable.getAccount().getAvatar(), status.getRebloggedAvatar(), @@ -767,7 +772,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { if (payloads instanceof List) for (Object item : (List) payloads) { if (Key.KEY_CREATED.equals(item)) { - setCreatedAt(status.getActionable().getCreatedAt(), status.getActionable().getEditedAt(), statusDisplayOptions); + setMetaData(status, statusDisplayOptions, listener); } } @@ -849,7 +854,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { } } - private static CharSequence getVisibilityDescription(Context context, Status.Visibility visibility) { + protected static CharSequence getVisibilityDescription(Context context, Status.Visibility visibility) { if (visibility == null) { return ""; @@ -1138,7 +1143,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { avatarInset.setVisibility(visibility); displayName.setVisibility(visibility); username.setVisibility(visibility); - timestampInfo.setVisibility(visibility); + metaInfo.setVisibility(visibility); contentWarningDescription.setVisibility(visibility); contentWarningButton.setVisibility(visibility); content.setVisibility(visibility); 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 333a06e8..1725dac9 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/StatusDetailedViewHolder.java +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/StatusDetailedViewHolder.java @@ -2,13 +2,18 @@ package com.keylesspalace.tusky.adapter; import android.content.Context; import android.graphics.drawable.Drawable; -import android.text.TextUtils; +import android.os.Build; +import android.text.SpannableStringBuilder; +import android.text.Spanned; import android.text.method.LinkMovementMethod; +import android.text.style.DynamicDrawableSpan; +import android.text.style.ImageSpan; import android.view.View; import android.widget.TextView; import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import androidx.appcompat.content.res.AppCompatResources; import androidx.recyclerview.widget.RecyclerView; import com.keylesspalace.tusky.R; @@ -16,19 +21,20 @@ import com.keylesspalace.tusky.entity.Status; import com.keylesspalace.tusky.interfaces.StatusActionListener; import com.keylesspalace.tusky.util.CardViewMode; import com.keylesspalace.tusky.util.LinkHelper; +import com.keylesspalace.tusky.util.NoUnderlineURLSpan; import com.keylesspalace.tusky.util.StatusDisplayOptions; import com.keylesspalace.tusky.viewdata.StatusViewData; import java.text.DateFormat; -import java.util.ArrayList; import java.util.Date; -import java.util.List; public class StatusDetailedViewHolder extends StatusBaseViewHolder { private final TextView reblogs; private final TextView favourites; private final View infoDivider; + private static final DateFormat dateFormat = DateFormat.getDateTimeInstance(DateFormat.DEFAULT, DateFormat.SHORT); + public StatusDetailedViewHolder(View view) { super(view); reblogs = view.findViewById(R.id.status_reblogs); @@ -37,17 +43,74 @@ public class StatusDetailedViewHolder extends StatusBaseViewHolder { } @Override - protected void setCreatedAt(Date createdAt, Date editedAt, StatusDisplayOptions statusDisplayOptions) { - DateFormat dateFormat = DateFormat.getDateTimeInstance(DateFormat.DEFAULT, DateFormat.SHORT); - Context context = timestampInfo.getContext(); - List list = new ArrayList<>(); + protected void setMetaData(StatusViewData.Concrete statusViewData, StatusDisplayOptions statusDisplayOptions, StatusActionListener listener) { + + Status status = statusViewData.getActionable(); + + Status.Visibility visibility = status.getVisibility(); + Context context = metaInfo.getContext(); + + Drawable visibilityIcon = getVisibilityIcon(visibility); + CharSequence visibilityString = getVisibilityDescription(context, visibility); + + SpannableStringBuilder sb = new SpannableStringBuilder(visibilityString); + + if (visibilityIcon != null) { + ImageSpan visibilityIconSpan = new ImageSpan( + visibilityIcon, + Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q ? DynamicDrawableSpan.ALIGN_CENTER : DynamicDrawableSpan.ALIGN_BASELINE + ); + sb.setSpan(visibilityIconSpan, 0, visibilityString.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + } + + String metadataJoiner = context.getString(R.string.metadata_joiner); + + Date createdAt = status.getCreatedAt(); if (createdAt != null) { - list.add(dateFormat.format(createdAt)); + + sb.append(" "); + sb.append(dateFormat.format(createdAt)); } + + Date editedAt = status.getEditedAt(); + if (editedAt != null) { - list.add(context.getString(R.string.post_edited, dateFormat.format(editedAt))); + String editedAtString = context.getString(R.string.post_edited, dateFormat.format(editedAt)); + + sb.append(metadataJoiner); + int spanStart = sb.length(); + int spanEnd = spanStart + editedAtString.length(); + + sb.append(editedAtString); + + if (statusViewData.getStatus().getEditedAt() != null) { + NoUnderlineURLSpan editedClickSpan = new NoUnderlineURLSpan("") { + @Override + public void onClick(@NonNull View view) { + listener.onShowEdits(getBindingAdapterPosition()); + } + }; + + sb.setSpan(editedClickSpan, spanStart, spanEnd, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + } } - timestampInfo.setText(TextUtils.join(context.getString(R.string.timestamp_joiner), list)); + + Status.Application app = status.getApplication(); + + if (app != null) { + + sb.append(metadataJoiner); + + if (app.getWebsite() != null) { + CharSequence text = LinkHelper.createClickableText(app.getName(), app.getWebsite()); + sb.append(text); + } else { + sb.append(app.getName()); + } + } + + metaInfo.setMovementMethod(LinkMovementMethod.getInstance()); + metaInfo.setText(sb); } private void setReblogAndFavCount(int reblogCount, int favCount, StatusActionListener listener) { @@ -85,21 +148,6 @@ public class StatusDetailedViewHolder extends StatusBaseViewHolder { }); } - private void setApplication(@Nullable Status.Application app) { - if (app != null) { - - timestampInfo.append(" • "); - - if (app.getWebsite() != null) { - CharSequence text = LinkHelper.createClickableText(app.getName(), app.getWebsite()); - timestampInfo.append(text); - timestampInfo.setMovementMethod(LinkMovementMethod.getInstance()); - } else { - timestampInfo.append(app.getName()); - } - } - } - @Override public void setupWithStatus(@NonNull final StatusViewData.Concrete status, @NonNull final StatusActionListener listener, @@ -107,8 +155,8 @@ public class StatusDetailedViewHolder extends StatusBaseViewHolder { @Nullable Object payloads) { // We never collapse statuses in the detail view StatusViewData.Concrete uncollapsedStatus = (status.isCollapsible() && status.isCollapsed()) ? - status.copyWithCollapsed(false) : - status; + status.copyWithCollapsed(false) : + status; super.setupWithStatus(uncollapsedStatus, listener, statusDisplayOptions, payloads); setupCard(uncollapsedStatus, CardViewMode.FULL_WIDTH, statusDisplayOptions, listener); // Always show card for detailed status @@ -121,17 +169,13 @@ public class StatusDetailedViewHolder extends StatusBaseViewHolder { } else { hideQuantitativeStats(); } - - setApplication(actionable.getApplication()); - - setStatusVisibility(actionable.getVisibility()); } } - private void setStatusVisibility(Status.Visibility visibility) { + private @Nullable Drawable getVisibilityIcon(@Nullable Status.Visibility visibility) { if (visibility == null) { - return; + return null; } int visibilityIcon; @@ -149,29 +193,26 @@ public class StatusDetailedViewHolder extends StatusBaseViewHolder { visibilityIcon = R.drawable.ic_email_24dp; break; default: - return; + return null; } - final Drawable visibilityDrawable = this.timestampInfo.getContext() - .getDrawable(visibilityIcon); + final Drawable visibilityDrawable = AppCompatResources.getDrawable( + this.metaInfo.getContext(), visibilityIcon + ); if (visibilityDrawable == null) { - return; + return null; } - final int size = (int) this.timestampInfo.getTextSize(); + final int size = (int) this.metaInfo.getTextSize(); visibilityDrawable.setBounds( 0, 0, size, size ); - visibilityDrawable.setTint(this.timestampInfo.getCurrentTextColor()); - this.timestampInfo.setCompoundDrawables( - visibilityDrawable, - null, - null, - null - ); + visibilityDrawable.setTint(this.metaInfo.getCurrentTextColor()); + + return visibilityDrawable; } private void hideQuantitativeStats() { 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 591b06cb..64b42eaa 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 @@ -83,7 +83,7 @@ public class ConversationViewHolder extends StatusBaseViewHolder { setDisplayName(account.getDisplayName(), account.getEmojis(), statusDisplayOptions); setUsername(account.getUsername()); - setCreatedAt(status.getCreatedAt(), status.getEditedAt(), statusDisplayOptions); + setMetaData(statusViewData, statusDisplayOptions, listener); setIsReply(status.getInReplyToId() != null); setFavourited(status.getFavourited()); setBookmarked(status.getBookmarked()); @@ -121,7 +121,7 @@ public class ConversationViewHolder extends StatusBaseViewHolder { if (payloads instanceof List) { for (Object item : (List) payloads) { if (Key.KEY_CREATED.equals(item)) { - setCreatedAt(status.getCreatedAt(), status.getEditedAt(), statusDisplayOptions); + setMetaData(statusViewData, statusDisplayOptions, listener); } } } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/viewthread/ViewThreadFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/viewthread/ViewThreadFragment.kt index 2f177b5f..d90c976b 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/viewthread/ViewThreadFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/viewthread/ViewThreadFragment.kt @@ -21,6 +21,7 @@ import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.widget.LinearLayout +import androidx.fragment.app.commit import androidx.fragment.app.viewModels import androidx.lifecycle.lifecycleScope import androidx.preference.PreferenceManager @@ -33,6 +34,7 @@ import com.keylesspalace.tusky.AccountListActivity import com.keylesspalace.tusky.AccountListActivity.Companion.newIntent import com.keylesspalace.tusky.BaseActivity import com.keylesspalace.tusky.R +import com.keylesspalace.tusky.components.viewthread.edits.ViewEditsFragment import com.keylesspalace.tusky.databinding.FragmentViewThreadBinding import com.keylesspalace.tusky.di.Injectable import com.keylesspalace.tusky.di.ViewModelFactory @@ -104,6 +106,7 @@ class ViewThreadFragment : SFragment(), OnRefreshListener, StatusActionListener, binding.toolbar.setNavigationOnClickListener { activity?.onBackPressedDispatcher?.onBackPressed() } + binding.toolbar.inflateMenu(R.menu.view_thread_toolbar) binding.toolbar.setOnMenuItemClickListener { menuItem -> when (menuItem.itemId) { R.id.action_reveal -> { @@ -325,6 +328,17 @@ class ViewThreadFragment : SFragment(), OnRefreshListener, StatusActionListener, viewModel.voteInPoll(choices, status) } + override fun onShowEdits(position: Int) { + val status = adapter.currentList[position] + val viewEditsFragment = ViewEditsFragment.newInstance(status.actionableId) + + parentFragmentManager.commit { + setCustomAnimations(R.anim.slide_from_right, R.anim.slide_to_left, R.anim.slide_from_left, R.anim.slide_to_right) + replace(R.id.fragment_container, viewEditsFragment, "ViewEditsFragment_$id") + addToBackStack(null) + } + } + companion object { private const val TAG = "ViewThreadFragment" diff --git a/app/src/main/java/com/keylesspalace/tusky/components/viewthread/ViewThreadViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/viewthread/ViewThreadViewModel.kt index 5b296999..91cdc62e 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/viewthread/ViewThreadViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/viewthread/ViewThreadViewModel.kt @@ -364,7 +364,9 @@ class ViewThreadViewModel @Inject constructor( } } - private fun Status.toViewData(detailed: Boolean = false): StatusViewData.Concrete { + private fun Status.toViewData( + detailed: Boolean = false + ): StatusViewData.Concrete { val oldStatus = (_uiState.value as? ThreadUiState.Success)?.statuses?.find { it.id == this.id } return toViewData( isShowingContent = oldStatus?.isShowingContent ?: (alwaysShowSensitiveMedia || !actionableStatus.sensitive), diff --git a/app/src/main/java/com/keylesspalace/tusky/components/viewthread/edits/ViewEditsAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/viewthread/edits/ViewEditsAdapter.kt new file mode 100644 index 00000000..204064f7 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/viewthread/edits/ViewEditsAdapter.kt @@ -0,0 +1,185 @@ +package com.keylesspalace.tusky.components.viewthread.edits + +import android.graphics.drawable.ColorDrawable +import android.graphics.drawable.Drawable +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import com.bumptech.glide.Glide +import com.keylesspalace.tusky.R +import com.keylesspalace.tusky.adapter.PollAdapter +import com.keylesspalace.tusky.adapter.PollAdapter.Companion.MULTIPLE +import com.keylesspalace.tusky.adapter.PollAdapter.Companion.SINGLE +import com.keylesspalace.tusky.databinding.ItemStatusEditBinding +import com.keylesspalace.tusky.entity.Attachment.Focus +import com.keylesspalace.tusky.entity.StatusEdit +import com.keylesspalace.tusky.interfaces.LinkListener +import com.keylesspalace.tusky.util.AbsoluteTimeFormatter +import com.keylesspalace.tusky.util.BindingHolder +import com.keylesspalace.tusky.util.ThemeUtils +import com.keylesspalace.tusky.util.aspectRatios +import com.keylesspalace.tusky.util.decodeBlurHash +import com.keylesspalace.tusky.util.emojify +import com.keylesspalace.tusky.util.hide +import com.keylesspalace.tusky.util.loadAvatar +import com.keylesspalace.tusky.util.parseAsMastodonHtml +import com.keylesspalace.tusky.util.setClickableText +import com.keylesspalace.tusky.util.show +import com.keylesspalace.tusky.util.visible +import com.keylesspalace.tusky.viewdata.toViewData + +class ViewEditsAdapter( + private val edits: List, + private val animateAvatars: Boolean, + private val animateEmojis: Boolean, + private val useBlurhash: Boolean, + private val listener: LinkListener +) : RecyclerView.Adapter>() { + + private val absoluteTimeFormatter = AbsoluteTimeFormatter() + + override fun onCreateViewHolder( + parent: ViewGroup, + viewType: Int + ): BindingHolder { + val binding = ItemStatusEditBinding.inflate(LayoutInflater.from(parent.context), parent, false) + binding.statusEditMediaPreview.clipToOutline = true + return BindingHolder(binding) + } + + override fun onBindViewHolder(holder: BindingHolder, position: Int) { + + val edit = edits[position] + + val binding = holder.binding + + val context = binding.root.context + + val avatarRadius: Int = context.resources + .getDimensionPixelSize(R.dimen.avatar_radius_48dp) + + loadAvatar(edit.account.avatar, binding.statusEditAvatar, avatarRadius, animateAvatars) + + val infoStringRes = if (position == edits.size - 1) { + R.string.status_created_info + } else { + R.string.status_edit_info + } + + val timestamp = absoluteTimeFormatter.format(edit.createdAt, false) + + binding.statusEditInfo.text = context.getString( + infoStringRes, + edit.account.name, + timestamp + ).emojify(edit.account.emojis, binding.statusEditInfo, animateEmojis) + + if (edit.spoilerText.isEmpty()) { + binding.statusEditContentWarningDescription.hide() + binding.statusEditContentWarningSeparator.hide() + } else { + binding.statusEditContentWarningDescription.show() + binding.statusEditContentWarningSeparator.show() + binding.statusEditContentWarningDescription.text = edit.spoilerText.emojify( + edit.emojis, + binding.statusEditContentWarningDescription, + animateEmojis + ) + } + + val emojifiedText = edit.content.parseAsMastodonHtml().emojify(edit.emojis, binding.statusEditContent, animateEmojis) + setClickableText(binding.statusEditContent, emojifiedText, emptyList(), emptyList(), listener) + + if (edit.poll == null) { + binding.statusEditPollOptions.hide() + binding.statusEditPollDescription.hide() + } else { + binding.statusEditPollOptions.show() + + // not used for now since not reported by the api + // https://github.com/mastodon/mastodon/issues/22571 + // binding.statusEditPollDescription.show() + + val pollAdapter = PollAdapter() + binding.statusEditPollOptions.adapter = pollAdapter + binding.statusEditPollOptions.layoutManager = LinearLayoutManager(context) + + pollAdapter.setup( + options = edit.poll.options.map { it.toViewData(false) }, + voteCount = 0, + votersCount = null, + emojis = edit.emojis, + mode = if (edit.poll.multiple) { // not reported by the api + MULTIPLE + } else { + SINGLE + }, + resultClickListener = null, + animateEmojis = animateEmojis, + enabled = false + ) + } + + if (edit.mediaAttachments.isEmpty()) { + binding.statusEditMediaPreview.hide() + binding.statusEditMediaSensitivity.hide() + } else { + binding.statusEditMediaPreview.show() + binding.statusEditMediaPreview.aspectRatios = edit.mediaAttachments.aspectRatios() + + binding.statusEditMediaPreview.forEachIndexed { index, _, imageView, descriptionIndicator -> + + val attachment = edit.mediaAttachments[index] + val hasDescription = !attachment.description.isNullOrBlank() + + if (hasDescription) { + imageView.contentDescription = attachment.description + } else { + imageView.contentDescription = + imageView.context.getString(R.string.action_view_media) + } + descriptionIndicator.visibility = if (hasDescription) View.VISIBLE else View.GONE + + val blurhash = attachment.blurhash + + val placeholder: Drawable = if (blurhash != null && useBlurhash) { + decodeBlurHash(context, blurhash) + } else { + ColorDrawable(ThemeUtils.getColor(context, R.attr.colorBackgroundAccent)) + } + + if (attachment.previewUrl.isNullOrEmpty()) { + imageView.removeFocalPoint() + Glide.with(imageView) + .load(placeholder) + .centerInside() + .into(imageView) + } else { + val focus: Focus? = attachment.meta?.focus + + if (focus != null) { + imageView.setFocalPoint(focus) + Glide.with(imageView.context) + .load(attachment.previewUrl) + .placeholder(placeholder) + .centerInside() + .addListener(imageView) + .into(imageView) + } else { + imageView.removeFocalPoint() + Glide.with(imageView) + .load(attachment.previewUrl) + .placeholder(placeholder) + .centerInside() + .into(imageView) + } + } + } + binding.statusEditMediaSensitivity.visible(edit.sensitive) + } + } + + override fun getItemCount() = edits.size +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/viewthread/edits/ViewEditsFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/viewthread/edits/ViewEditsFragment.kt new file mode 100644 index 00000000..d1487fef --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/viewthread/edits/ViewEditsFragment.kt @@ -0,0 +1,152 @@ +/* Copyright 2022 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.viewthread.edits + +import android.os.Bundle +import android.util.Log +import android.view.View +import android.widget.LinearLayout +import androidx.fragment.app.Fragment +import androidx.fragment.app.viewModels +import androidx.lifecycle.lifecycleScope +import androidx.preference.PreferenceManager +import androidx.recyclerview.widget.DividerItemDecoration +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.SimpleItemAnimator +import com.keylesspalace.tusky.BottomSheetActivity +import com.keylesspalace.tusky.R +import com.keylesspalace.tusky.StatusListActivity +import com.keylesspalace.tusky.components.account.AccountActivity +import com.keylesspalace.tusky.databinding.FragmentViewThreadBinding +import com.keylesspalace.tusky.di.Injectable +import com.keylesspalace.tusky.di.ViewModelFactory +import com.keylesspalace.tusky.interfaces.LinkListener +import com.keylesspalace.tusky.settings.PrefKeys +import com.keylesspalace.tusky.util.hide +import com.keylesspalace.tusky.util.show +import com.keylesspalace.tusky.util.viewBinding +import kotlinx.coroutines.launch +import java.io.IOException +import javax.inject.Inject + +class ViewEditsFragment : Fragment(R.layout.fragment_view_thread), LinkListener, Injectable { + + @Inject + lateinit var viewModelFactory: ViewModelFactory + + private val viewModel: ViewEditsViewModel by viewModels { viewModelFactory } + + private val binding by viewBinding(FragmentViewThreadBinding::bind) + + private lateinit var statusId: String + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + + binding.toolbar.setNavigationOnClickListener { + activity?.onBackPressedDispatcher?.onBackPressed() + } + binding.toolbar.title = getString(R.string.title_edits) + binding.swipeRefreshLayout.isEnabled = false + + binding.recyclerView.setHasFixedSize(true) + binding.recyclerView.layoutManager = LinearLayoutManager(context) + + val divider = DividerItemDecoration(context, LinearLayout.VERTICAL) + binding.recyclerView.addItemDecoration(divider) + (binding.recyclerView.itemAnimator as SimpleItemAnimator).supportsChangeAnimations = false + + statusId = requireArguments().getString(STATUS_ID_EXTRA)!! + val preferences = PreferenceManager.getDefaultSharedPreferences(requireContext()) + + val animateAvatars = preferences.getBoolean(PrefKeys.ANIMATE_GIF_AVATARS, false) + val animateEmojis = preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false) + val useBlurhash = preferences.getBoolean(PrefKeys.USE_BLURHASH, true) + + viewLifecycleOwner.lifecycleScope.launch { + viewModel.uiState.collect { uiState -> + when (uiState) { + EditsUiState.Initial -> {} + EditsUiState.Loading -> { + binding.recyclerView.hide() + binding.statusView.hide() + binding.progressBar.show() + } + is EditsUiState.Error -> { + Log.w(TAG, "failed to load edits", uiState.throwable) + + binding.recyclerView.hide() + binding.statusView.show() + binding.progressBar.hide() + + if (uiState.throwable is IOException) { + binding.statusView.setup(R.drawable.elephant_offline, R.string.error_network) { + viewModel.loadEdits(statusId, force = true) + } + } else { + binding.statusView.setup(R.drawable.elephant_error, R.string.error_generic) { + viewModel.loadEdits(statusId, force = true) + } + } + } + is EditsUiState.Success -> { + binding.recyclerView.show() + binding.statusView.hide() + binding.progressBar.hide() + + binding.recyclerView.adapter = ViewEditsAdapter( + edits = uiState.edits, + animateAvatars = animateAvatars, + animateEmojis = animateEmojis, + useBlurhash = useBlurhash, + listener = this@ViewEditsFragment + ) + } + } + } + } + + viewModel.loadEdits(statusId) + } + + override fun onViewAccount(id: String) { + bottomSheetActivity?.startActivityWithSlideInAnimation(AccountActivity.getIntent(requireContext(), id)) + } + + override fun onViewTag(tag: String) { + bottomSheetActivity?.startActivityWithSlideInAnimation(StatusListActivity.newHashtagIntent(requireContext(), tag)) + } + + override fun onViewUrl(url: String) { + bottomSheetActivity?.viewUrl(url) + } + + private val bottomSheetActivity + get() = (activity as? BottomSheetActivity) + + companion object { + private const val TAG = "ViewEditsFragment" + + private const val STATUS_ID_EXTRA = "id" + + fun newInstance(statusId: String): ViewEditsFragment { + val arguments = Bundle(1) + val fragment = ViewEditsFragment() + arguments.putString(STATUS_ID_EXTRA, statusId) + fragment.arguments = arguments + return fragment + } + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/viewthread/edits/ViewEditsViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/viewthread/edits/ViewEditsViewModel.kt new file mode 100644 index 00000000..a76078ed --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/viewthread/edits/ViewEditsViewModel.kt @@ -0,0 +1,63 @@ +/* Copyright 2022 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.viewthread.edits + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import at.connyduck.calladapter.networkresult.fold +import com.keylesspalace.tusky.entity.StatusEdit +import com.keylesspalace.tusky.network.MastodonApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.launch +import javax.inject.Inject + +class ViewEditsViewModel @Inject constructor( + private val api: MastodonApi +) : ViewModel() { + + private val _uiState: MutableStateFlow = MutableStateFlow(EditsUiState.Initial) + val uiState: Flow + get() = _uiState + + fun loadEdits(statusId: String, force: Boolean = false, refreshing: Boolean = false) { + if (force || _uiState.value is EditsUiState.Initial) { + if (!refreshing) { + _uiState.value = EditsUiState.Loading + } + viewModelScope.launch { + api.statusEdits(statusId).fold( + { edits -> + val sortedEdits = edits.sortedBy { edit -> edit.createdAt }.reversed() + _uiState.value = EditsUiState.Success(sortedEdits) + }, + { throwable -> + _uiState.value = EditsUiState.Error(throwable) + } + ) + } + } + } +} + +sealed interface EditsUiState { + object Initial : EditsUiState + object Loading : EditsUiState + class Error(val throwable: Throwable) : EditsUiState + data class Success( + val edits: List + ) : EditsUiState +} 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 573689de..bc202f14 100644 --- a/app/src/main/java/com/keylesspalace/tusky/di/FragmentBuildersModule.kt +++ b/app/src/main/java/com/keylesspalace/tusky/di/FragmentBuildersModule.kt @@ -31,6 +31,7 @@ import com.keylesspalace.tusky.components.search.fragments.SearchHashtagsFragmen import com.keylesspalace.tusky.components.search.fragments.SearchStatusesFragment import com.keylesspalace.tusky.components.timeline.TimelineFragment import com.keylesspalace.tusky.components.viewthread.ViewThreadFragment +import com.keylesspalace.tusky.components.viewthread.edits.ViewEditsFragment import com.keylesspalace.tusky.fragment.AccountListFragment import com.keylesspalace.tusky.fragment.NotificationsFragment import dagger.Module @@ -51,6 +52,9 @@ abstract class FragmentBuildersModule { @ContributesAndroidInjector abstract fun viewThreadFragment(): ViewThreadFragment + @ContributesAndroidInjector + abstract fun viewEditsFragment(): ViewEditsFragment + @ContributesAndroidInjector abstract fun timelineFragment(): TimelineFragment 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 726bb09b..aab1fa3d 100644 --- a/app/src/main/java/com/keylesspalace/tusky/di/ViewModelFactory.kt +++ b/app/src/main/java/com/keylesspalace/tusky/di/ViewModelFactory.kt @@ -19,6 +19,7 @@ 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.viewthread.ViewThreadViewModel +import com.keylesspalace.tusky.components.viewthread.edits.ViewEditsViewModel import com.keylesspalace.tusky.viewmodel.AccountsInListViewModel import com.keylesspalace.tusky.viewmodel.EditProfileViewModel import com.keylesspalace.tusky.viewmodel.ListsViewModel @@ -118,6 +119,11 @@ abstract class ViewModelModule { @ViewModelKey(ViewThreadViewModel::class) internal abstract fun viewThreadViewModel(viewModel: ViewThreadViewModel): ViewModel + @Binds + @IntoMap + @ViewModelKey(ViewEditsViewModel::class) + internal abstract fun viewEditsViewModel(viewModel: ViewEditsViewModel): ViewModel + @Binds @IntoMap @ViewModelKey(AccountMediaViewModel::class) diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/StatusEdit.kt b/app/src/main/java/com/keylesspalace/tusky/entity/StatusEdit.kt new file mode 100644 index 00000000..0e77b0fd --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/entity/StatusEdit.kt @@ -0,0 +1,15 @@ +package com.keylesspalace.tusky.entity + +import com.google.gson.annotations.SerializedName +import java.util.Date + +data class StatusEdit( + val content: String, + @SerializedName("spoiler_text") val spoilerText: String, + val sensitive: Boolean, + @SerializedName("created_at") val createdAt: Date, + val account: TimelineAccount, + val poll: Poll?, + @SerializedName("media_attachments") val mediaAttachments: List, + val emojis: List +) diff --git a/app/src/main/java/com/keylesspalace/tusky/interfaces/StatusActionListener.java b/app/src/main/java/com/keylesspalace/tusky/interfaces/StatusActionListener.java index ec37680c..9171b420 100644 --- a/app/src/main/java/com/keylesspalace/tusky/interfaces/StatusActionListener.java +++ b/app/src/main/java/com/keylesspalace/tusky/interfaces/StatusActionListener.java @@ -63,4 +63,6 @@ public interface StatusActionListener extends LinkListener { void onVoteInPoll(int position, @NonNull List choices); + default void onShowEdits(int position) {} + } 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 2279778b..c4e0425d 100644 --- a/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.kt +++ b/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.kt @@ -39,6 +39,7 @@ import com.keylesspalace.tusky.entity.ScheduledStatus import com.keylesspalace.tusky.entity.SearchResult import com.keylesspalace.tusky.entity.Status 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 io.reactivex.rxjava3.core.Single @@ -194,6 +195,11 @@ interface MastodonApi { @Path("id") statusId: String ): NetworkResult + @GET("api/v1/statuses/{id}/history") + suspend fun statusEdits( + @Path("id") statusId: String + ): NetworkResult> + @GET("api/v1/statuses/{id}/reblogged_by") suspend fun statusRebloggedBy( @Path("id") statusId: String, diff --git a/app/src/main/java/com/keylesspalace/tusky/viewdata/StatusViewData.kt b/app/src/main/java/com/keylesspalace/tusky/viewdata/StatusViewData.kt index ac9df9c2..f7125dc5 100644 --- a/app/src/main/java/com/keylesspalace/tusky/viewdata/StatusViewData.kt +++ b/app/src/main/java/com/keylesspalace/tusky/viewdata/StatusViewData.kt @@ -40,7 +40,6 @@ sealed class StatusViewData { * * @return Whether the post is collapsed or fully expanded. */ - /** Whether the status meets the requirement to be collapse */ val isCollapsed: Boolean, val isDetailed: Boolean = false ) : StatusViewData() { diff --git a/app/src/main/res/layout-sw640dp/fragment_view_thread.xml b/app/src/main/res/layout-sw640dp/fragment_view_thread.xml index 0c661b0b..69163b8c 100644 --- a/app/src/main/res/layout-sw640dp/fragment_view_thread.xml +++ b/app/src/main/res/layout-sw640dp/fragment_view_thread.xml @@ -14,7 +14,6 @@ android:id="@+id/toolbar" android:layout_width="match_parent" android:layout_height="?attr/actionBarSize" - app:menu="@menu/view_thread_toolbar" app:navigationContentDescription="@string/abc_action_bar_up_description" app:navigationIcon="?attr/homeAsUpIndicator" app:title="@string/title_view_thread" /> diff --git a/app/src/main/res/layout/fragment_view_thread.xml b/app/src/main/res/layout/fragment_view_thread.xml index faa4e421..63584425 100644 --- a/app/src/main/res/layout/fragment_view_thread.xml +++ b/app/src/main/res/layout/fragment_view_thread.xml @@ -14,7 +14,6 @@ android:id="@+id/toolbar" android:layout_width="match_parent" android:layout_height="?attr/actionBarSize" - app:menu="@menu/view_thread_toolbar" app:navigationContentDescription="@string/abc_action_bar_up_description" app:navigationIcon="?attr/homeAsUpIndicator" app:title="@string/title_view_thread" /> diff --git a/app/src/main/res/layout/item_conversation.xml b/app/src/main/res/layout/item_conversation.xml index e523471f..f8d20f7b 100644 --- a/app/src/main/res/layout/item_conversation.xml +++ b/app/src/main/res/layout/item_conversation.xml @@ -92,7 +92,7 @@ android:textSize="?attr/status_text_medium" android:textStyle="normal|bold" app:layout_constrainedWidth="true" - app:layout_constraintEnd_toStartOf="@id/status_timestamp_info" + app:layout_constraintEnd_toStartOf="@id/status_meta_info" app:layout_constraintHorizontal_bias="0" app:layout_constraintStart_toEndOf="@id/status_avatar" app:layout_constraintTop_toBottomOf="@id/conversation_name" @@ -106,13 +106,13 @@ android:maxLines="1" android:textColor="?android:textColorSecondary" android:textSize="?attr/status_text_medium" - app:layout_constraintEnd_toStartOf="@id/status_timestamp_info" + app:layout_constraintEnd_toStartOf="@id/status_meta_info" app:layout_constraintStart_toEndOf="@id/status_display_name" app:layout_constraintTop_toTopOf="@id/status_display_name" tools:text="\@Entenhausen@birbsarecooooooooooool.site" /> @@ -36,6 +37,7 @@ android:layout_height="wrap_content" android:ellipsize="end" android:lines="1" + android:textColor="?android:attr/textColorPrimary" android:textSize="?attr/status_text_medium" app:buttonTint="@color/compound_button_color" tools:text="Option 1" /> diff --git a/app/src/main/res/layout/item_status.xml b/app/src/main/res/layout/item_status.xml index 38a2ac35..b0d0a620 100644 --- a/app/src/main/res/layout/item_status.xml +++ b/app/src/main/res/layout/item_status.xml @@ -70,7 +70,7 @@ android:textSize="?attr/status_text_medium" android:textStyle="normal|bold" app:layout_constrainedWidth="true" - app:layout_constraintEnd_toStartOf="@id/status_timestamp_info" + app:layout_constraintEnd_toStartOf="@id/status_meta_info" app:layout_constraintHorizontal_bias="0" app:layout_constraintStart_toEndOf="@id/status_avatar" app:layout_constraintTop_toBottomOf="@id/status_info" @@ -85,13 +85,13 @@ android:maxLines="1" android:textColor="?android:textColorSecondary" android:textSize="?attr/status_text_medium" - app:layout_constraintEnd_toStartOf="@id/status_timestamp_info" + app:layout_constraintEnd_toStartOf="@id/status_meta_info" app:layout_constraintStart_toEndOf="@id/status_display_name" app:layout_constraintTop_toTopOf="@id/status_display_name" tools:text="\@Entenhausen@birbsarecooooooooooool.site" /> + app:layout_constraintTop_toBottomOf="@id/status_meta_info" /> + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/item_status_notification.xml b/app/src/main/res/layout/item_status_notification.xml index 1c1a47ed..558fd11c 100644 --- a/app/src/main/res/layout/item_status_notification.xml +++ b/app/src/main/res/layout/item_status_notification.xml @@ -51,7 +51,7 @@ android:id="@+id/status_username" android:layout_width="wrap_content" android:layout_height="wrap_content" - android:layout_toStartOf="@+id/status_timestamp_info" + android:layout_toStartOf="@+id/status_meta_info" android:layout_toEndOf="@id/status_display_name" android:ellipsize="end" android:maxLines="1" @@ -60,7 +60,7 @@ tools:text="\@Entenhausen" /> :%s: %s * - " • " + " • " public diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 2a66137f..db103117 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -55,6 +55,7 @@ Announcements Licenses Followed Hashtags + Edits \@%s %s boosted @@ -712,4 +713,9 @@ Unfollow #%s? + + %1$s edited %2$s + + %1$s created %2$s +