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
This commit is contained in:
Konrad Pozniak 2023-01-02 14:09:18 +01:00 committed by GitHub
parent 8c0f02cf33
commit 61a45ae376
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
26 changed files with 731 additions and 75 deletions

View file

@ -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);

View file

@ -38,7 +38,9 @@ class PollAdapter : RecyclerView.Adapter<BindingHolder<ItemPollBinding>>() {
private var emojis: List<Emoji> = emptyList()
private var resultClickListener: View.OnClickListener? = null
private var animateEmojis = false
private var enabled = true
@JvmOverloads
fun setup(
options: List<PollOptionViewData>,
voteCount: Int,
@ -46,7 +48,8 @@ class PollAdapter : RecyclerView.Adapter<BindingHolder<ItemPollBinding>>() {
emojis: List<Emoji>,
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<BindingHolder<ItemPollBinding>>() {
this.mode = mode
this.resultClickListener = resultClickListener
this.animateEmojis = animateEmojis
this.enabled = enabled
notifyDataSetChanged()
}
@ -82,6 +86,9 @@ class PollAdapter : RecyclerView.Adapter<BindingHolder<ItemPollBinding>>() {
radioButton.visible(mode == SINGLE)
checkBox.visible(mode == MULTIPLE)
radioButton.isEnabled = enabled
checkBox.isEnabled = enabled
when (mode) {
RESULT -> {
val percent = calculatePercent(option.votesCount, votersCount, voteCount)

View file

@ -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);

View file

@ -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<String> 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() {

View file

@ -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);
}
}
}

View file

@ -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"

View file

@ -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),

View file

@ -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<StatusEdit>,
private val animateAvatars: Boolean,
private val animateEmojis: Boolean,
private val useBlurhash: Boolean,
private val listener: LinkListener
) : RecyclerView.Adapter<BindingHolder<ItemStatusEditBinding>>() {
private val absoluteTimeFormatter = AbsoluteTimeFormatter()
override fun onCreateViewHolder(
parent: ViewGroup,
viewType: Int
): BindingHolder<ItemStatusEditBinding> {
val binding = ItemStatusEditBinding.inflate(LayoutInflater.from(parent.context), parent, false)
binding.statusEditMediaPreview.clipToOutline = true
return BindingHolder(binding)
}
override fun onBindViewHolder(holder: BindingHolder<ItemStatusEditBinding>, 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
}

View file

@ -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 <http://www.gnu.org/licenses>. */
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
}
}
}

View file

@ -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 <http://www.gnu.org/licenses>. */
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<EditsUiState> = MutableStateFlow(EditsUiState.Initial)
val uiState: Flow<EditsUiState>
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<StatusEdit>
) : EditsUiState
}

View file

@ -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

View file

@ -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)

View file

@ -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<Attachment>,
val emojis: List<Emoji>
)

View file

@ -63,4 +63,6 @@ public interface StatusActionListener extends LinkListener {
void onVoteInPoll(int position, @NonNull List<Integer> choices);
default void onShowEdits(int position) {}
}

View file

@ -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<StatusContext>
@GET("api/v1/statuses/{id}/history")
suspend fun statusEdits(
@Path("id") statusId: String
): NetworkResult<List<StatusEdit>>
@GET("api/v1/statuses/{id}/reblogged_by")
suspend fun statusRebloggedBy(
@Path("id") statusId: String,

View file

@ -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() {

View file

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

View file

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

View file

@ -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" />
<TextView
android:id="@+id/status_timestamp_info"
android:id="@+id/status_meta_info"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="4dp"

View file

@ -26,6 +26,7 @@
android:id="@+id/status_poll_radio_button"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textColor="?android:attr/textColorPrimary"
android:textSize="?attr/status_text_medium"
app:buttonTint="@color/compound_button_color"
tools:text="Option 1" />
@ -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" />

View file

@ -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" />
<TextView
android:id="@+id/status_timestamp_info"
android:id="@+id/status_meta_info"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="4dp"

View file

@ -266,7 +266,7 @@
tools:text="7 votes • 7 hours remaining" />
<TextView
android:id="@+id/status_timestamp_info"
android:id="@+id/status_meta_info"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="14dp"
@ -274,6 +274,7 @@
android:layout_marginEnd="14dp"
android:drawablePadding="4dp"
android:importantForAccessibility="no"
android:lineSpacingMultiplier="1.1"
android:textColor="?android:textColorTertiary"
android:textSize="?attr/status_text_medium"
app:layout_constraintLeft_toLeftOf="parent"
@ -285,7 +286,7 @@
android:id="@+id/status_info_divider"
android:layout_width="match_parent"
android:layout_height="1dp"
android:layout_below="@id/status_timestamp_info"
android:layout_below="@id/status_meta_info"
android:layout_marginStart="14dp"
android:layout_marginTop="6dp"
android:layout_marginEnd="14dp"
@ -293,7 +294,7 @@
android:importantForAccessibility="no"
android:paddingStart="16dp"
android:paddingEnd="16dp"
app:layout_constraintTop_toBottomOf="@id/status_timestamp_info" />
app:layout_constraintTop_toBottomOf="@id/status_meta_info" />
<TextView
android:id="@+id/status_reblogs"

View file

@ -0,0 +1,148 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center_vertical"
android:orientation="horizontal"
android:paddingBottom="6dp">
<ImageView
android:id="@+id/status_edit_avatar"
android:layout_width="48dp"
android:layout_height="48dp"
android:layout_marginStart="14dp"
android:layout_marginTop="14dp"
android:contentDescription="@string/action_view_profile"
android:importantForAccessibility="no"
android:scaleType="centerCrop"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:src="@drawable/avatar_default" />
<TextView
android:id="@+id/status_edit_info"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="14dp"
android:layout_marginTop="10dp"
android:ellipsize="end"
android:maxLines="2"
android:textColor="?android:textColorPrimary"
android:textSize="?attr/status_text_medium"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@id/status_edit_avatar"
app:layout_constraintTop_toTopOf="parent"
tools:text="\@Tusky edited 18th December 2022" />
<TextView
android:id="@+id/status_edit_content_warning_description"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="14dp"
android:layout_marginTop="4dp"
android:layout_marginEnd="14dp"
android:layout_marginBottom="8dp"
android:hyphenationFrequency="full"
android:importantForAccessibility="no"
android:lineSpacingMultiplier="1.1"
android:textColor="?android:textColorPrimary"
android:textSize="?attr/status_text_medium"
android:visibility="gone"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/status_edit_avatar"
tools:text="content warning which is very long and it doesn't fit"
tools:visibility="visible" />
<View
android:id="@+id/status_edit_content_warning_separator"
android:layout_width="match_parent"
android:layout_height="1dp"
android:layout_marginStart="14dp"
android:layout_marginTop="4dp"
android:layout_marginEnd="14dp"
android:layout_marginBottom="4dp"
android:background="?android:textColorPrimary"
android:importantForAccessibility="no"
android:paddingLeft="16dp"
android:paddingRight="16dp"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/status_edit_content_warning_description" />
<TextView
android:id="@+id/status_edit_content"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="14dp"
android:layout_marginTop="8dp"
android:layout_marginEnd="14dp"
android:layout_marginBottom="8dp"
android:focusable="true"
android:hyphenationFrequency="full"
android:importantForAccessibility="no"
android:lineSpacingMultiplier="1.1"
android:textColor="?android:textColorPrimary"
android:textSize="?attr/status_text_medium"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/status_edit_content_warning_separator"
tools:text="This is an edited status" />
<com.keylesspalace.tusky.view.MediaPreviewLayout
android:id="@+id/status_edit_media_preview"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="14dp"
android:layout_marginTop="@dimen/status_media_preview_margin_top"
android:layout_marginEnd="14dp"
android:layout_marginBottom="6dp"
android:background="@drawable/media_preview_outline"
android:importantForAccessibility="noHideDescendants"
app:layout_constraintTop_toBottomOf="@id/status_edit_content" />
<TextView
android:id="@+id/status_edit_media_sensitivity"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="14dp"
android:layout_marginTop="6dp"
android:layout_marginEnd="14dp"
android:layout_marginBottom="6dp"
android:text="@string/post_sensitive_media_title"
android:textColor="?android:attr/textColorTertiary"
android:textSize="?attr/status_text_medium"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/status_edit_media_preview" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/status_edit_poll_options"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="14dp"
android:layout_marginTop="4dp"
android:layout_marginEnd="14dp"
android:layout_marginBottom="4dp"
android:nestedScrollingEnabled="false"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/status_edit_media_sensitivity" />
<!-- hidden because as of Mastodon 4.0.2 we don't get this info via the api -->
<TextView
android:id="@+id/status_edit_poll_description"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="14dp"
android:layout_marginTop="6dp"
android:layout_marginEnd="14dp"
android:textSize="?attr/status_text_medium"
android:visibility="gone"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/status_edit_poll_options"
tools:text="ends at 12:30" />
</androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -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" />
<TextView
android:id="@+id/status_timestamp_info"
android:id="@+id/status_meta_info"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_alignParentEnd="true"

View file

@ -12,7 +12,7 @@
<string name="emoji_shortcode_format" translatable="false">:%s:</string>
<string name="post_timestamp_with_edited_indicator" translatable="false">%s *</string>
<string name="timestamp_joiner" translatable="false">" • "</string>
<string name="metadata_joiner" translatable="false">" • "</string>
<string-array name="post_privacy_values">
<item>public</item>

View file

@ -55,6 +55,7 @@
<string name="title_announcements">Announcements</string>
<string name="title_licenses">Licenses</string>
<string name="title_followed_hashtags">Followed Hashtags</string>
<string name="title_edits">Edits</string>
<string name="post_username_format">\@%s</string>
<string name="post_boosted_format">%s boosted</string>
@ -712,4 +713,9 @@
<string name="action_unfollow_hashtag_format">Unfollow #%s?</string>
<!--@Tusky edited 19th December 2022 13:37 -->
<string name="status_edit_info">%1$s edited %2$s</string>
<!--@Tusky created 19th December 2022 13:12 -->
<string name="status_created_info">%1$s created %2$s</string>
</resources>