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:
parent
8c0f02cf33
commit
61a45ae376
26 changed files with 731 additions and 75 deletions
|
@ -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);
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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() {
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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"
|
||||
|
||||
|
|
|
@ -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),
|
||||
|
|
|
@ -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
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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>
|
||||
)
|
|
@ -63,4 +63,6 @@ public interface StatusActionListener extends LinkListener {
|
|||
|
||||
void onVoteInPoll(int position, @NonNull List<Integer> choices);
|
||||
|
||||
default void onShowEdits(int position) {}
|
||||
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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() {
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue