Timeline a11y (#1059)
* Improve timeline accessibility * Improve a11y description and actions in timeline * Refactor timeline accessibility handling, add more actions * Update app/src/main/java/com/keylesspalace/tusky/adapter/StatusViewHolder.java Co-Authored-By: charlag <charlag@tutanota.com> * Add a11y actions for links, hashtags and mentions, enable for detailed. * A11y delegate: Add open reblogger action, cleanup * a11y delegate: add reblogs/boosts, improve interrupts * a11y delegate: add reblogs/boosts, improve interrupts * a11y delegate: add to notifications fragment
This commit is contained in:
parent
df8c78c81a
commit
479d210e64
15 changed files with 646 additions and 78 deletions
|
|
@ -2,12 +2,6 @@ package com.keylesspalace.tusky.adapter;
|
|||
|
||||
import android.content.Context;
|
||||
import android.graphics.drawable.Drawable;
|
||||
import androidx.annotation.DrawableRes;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.appcompat.content.res.AppCompatResources;
|
||||
import androidx.recyclerview.widget.RecyclerView;
|
||||
import android.text.InputFilter;
|
||||
import android.text.Spanned;
|
||||
import android.text.TextUtils;
|
||||
import android.view.View;
|
||||
|
|
@ -28,21 +22,26 @@ import com.keylesspalace.tusky.util.CustomEmojiHelper;
|
|||
import com.keylesspalace.tusky.util.DateUtils;
|
||||
import com.keylesspalace.tusky.util.HtmlUtils;
|
||||
import com.keylesspalace.tusky.util.LinkHelper;
|
||||
import com.keylesspalace.tusky.util.SmartLengthInputFilter;
|
||||
import com.keylesspalace.tusky.util.ThemeUtils;
|
||||
import com.keylesspalace.tusky.view.MediaPreviewImageView;
|
||||
import com.keylesspalace.tusky.viewdata.StatusViewData;
|
||||
import com.mikepenz.iconics.utils.Utils;
|
||||
import com.squareup.picasso.Picasso;
|
||||
|
||||
import java.text.NumberFormat;
|
||||
import java.text.SimpleDateFormat;
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.lang.CharSequence;
|
||||
|
||||
import androidx.annotation.DrawableRes;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.appcompat.content.res.AppCompatResources;
|
||||
import androidx.recyclerview.widget.RecyclerView;
|
||||
import at.connyduck.sparkbutton.SparkButton;
|
||||
import at.connyduck.sparkbutton.SparkEventListener;
|
||||
import kotlin.collections.CollectionsKt;
|
||||
|
||||
public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
|
||||
|
||||
|
|
@ -70,6 +69,8 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
|
|||
private SimpleDateFormat shortSdf;
|
||||
private SimpleDateFormat longSdf;
|
||||
|
||||
private final NumberFormat numberFormat = NumberFormat.getNumberInstance();
|
||||
|
||||
protected StatusBaseViewHolder(View itemView, boolean useAbsoluteTime) {
|
||||
super(itemView);
|
||||
displayName = itemView.findViewById(R.id.status_display_name);
|
||||
|
|
@ -83,7 +84,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
|
|||
moreButton = itemView.findViewById(R.id.status_more);
|
||||
reblogged = false;
|
||||
favourited = false;
|
||||
mediaPreviews = new MediaPreviewImageView[] {
|
||||
mediaPreviews = new MediaPreviewImageView[]{
|
||||
itemView.findViewById(R.id.status_media_preview_0),
|
||||
itemView.findViewById(R.id.status_media_preview_1),
|
||||
itemView.findViewById(R.id.status_media_preview_2),
|
||||
|
|
@ -121,10 +122,10 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
|
|||
}
|
||||
|
||||
protected void setSpoilerAndContent(boolean expanded,
|
||||
@NonNull Spanned content,
|
||||
@NonNull Spanned content,
|
||||
@Nullable String spoilerText,
|
||||
@Nullable Status.Mention[] mentions,
|
||||
@NonNull List<Emoji> emojis,
|
||||
@Nullable Status.Mention[] mentions,
|
||||
@NonNull List<Emoji> emojis,
|
||||
final StatusActionListener listener) {
|
||||
if (TextUtils.isEmpty(spoilerText)) {
|
||||
contentWarningDescription.setVisibility(View.GONE);
|
||||
|
|
@ -156,9 +157,9 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
|
|||
Spanned emojifiedText = CustomEmojiHelper.emojifyText(content, emojis, this.content);
|
||||
LinkHelper.setClickableText(this.content, emojifiedText, mentions, listener);
|
||||
} else {
|
||||
LinkHelper.setClickableMentions(this.content, mentions, listener);
|
||||
LinkHelper.setClickableMentions(this.content, mentions, listener);
|
||||
}
|
||||
if(TextUtils.isEmpty(this.content.getText())) {
|
||||
if (TextUtils.isEmpty(this.content.getText())) {
|
||||
this.content.setVisibility(View.GONE);
|
||||
} else {
|
||||
this.content.setVisibility(View.VISIBLE);
|
||||
|
|
@ -178,37 +179,52 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
|
|||
|
||||
protected void setCreatedAt(@Nullable Date createdAt) {
|
||||
if (useAbsoluteTime) {
|
||||
String time;
|
||||
if (createdAt != null) {
|
||||
if (System.currentTimeMillis() - createdAt.getTime() > 86400000L) {
|
||||
time = longSdf.format(createdAt);
|
||||
} else {
|
||||
time = shortSdf.format(createdAt);
|
||||
}
|
||||
} else {
|
||||
time = "??:??:??";
|
||||
}
|
||||
timestampInfo.setText(time);
|
||||
timestampInfo.setText(getAbsoluteTime(createdAt));
|
||||
} else {
|
||||
// This is the visible timestampInfo.
|
||||
String readout;
|
||||
/* This one is for screen-readers. Frequently, they would mispronounce timestamps like "17m"
|
||||
* as 17 meters instead of minutes. */
|
||||
CharSequence readoutAloud;
|
||||
if (createdAt != null) {
|
||||
long then = createdAt.getTime();
|
||||
long now = new Date().getTime();
|
||||
readout = DateUtils.getRelativeTimeSpanString(timestampInfo.getContext(), then, now);
|
||||
readoutAloud = android.text.format.DateUtils.getRelativeTimeSpanString(then, now,
|
||||
} else {
|
||||
// unknown minutes~
|
||||
readout = "?m";
|
||||
}
|
||||
timestampInfo.setText(readout);
|
||||
}
|
||||
}
|
||||
|
||||
private String getAbsoluteTime(@Nullable Date createdAt) {
|
||||
String time;
|
||||
if (createdAt != null) {
|
||||
if (System.currentTimeMillis() - createdAt.getTime() > 86400000L) {
|
||||
time = longSdf.format(createdAt);
|
||||
} else {
|
||||
time = shortSdf.format(createdAt);
|
||||
}
|
||||
} else {
|
||||
time = "??:??:??";
|
||||
}
|
||||
return time;
|
||||
}
|
||||
|
||||
private CharSequence getCreatedAtDescription(@Nullable Date createdAt) {
|
||||
if (useAbsoluteTime) {
|
||||
return getAbsoluteTime(createdAt);
|
||||
} else {
|
||||
/* This one is for screen-readers. Frequently, they would mispronounce timestamps like "17m"
|
||||
* as 17 meters instead of minutes. */
|
||||
|
||||
if (createdAt != null) {
|
||||
long then = createdAt.getTime();
|
||||
long now = new Date().getTime();
|
||||
return android.text.format.DateUtils.getRelativeTimeSpanString(then, now,
|
||||
android.text.format.DateUtils.SECOND_IN_MILLIS,
|
||||
android.text.format.DateUtils.FORMAT_ABBREV_RELATIVE);
|
||||
} else {
|
||||
// unknown minutes~
|
||||
readout = "?m";
|
||||
readoutAloud = "? minutes";
|
||||
return "? minutes";
|
||||
}
|
||||
timestampInfo.setText(readout);
|
||||
timestampInfo.setContentDescription(readoutAloud);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -464,7 +480,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
|
|||
listener.onReply(position);
|
||||
}
|
||||
});
|
||||
if(reblogButton != null) {
|
||||
if (reblogButton != null) {
|
||||
reblogButton.setEventListener(new SparkEventListener() {
|
||||
@Override
|
||||
public void onEvent(ImageView button, boolean buttonState) {
|
||||
|
|
@ -554,5 +570,125 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
|
|||
|
||||
setSpoilerAndContent(status.isExpanded(), status.getContent(), status.getSpoilerText(), status.getMentions(), status.getStatusEmojis(), listener);
|
||||
|
||||
setContentDescription(status);
|
||||
// Workaround for RecyclerView 1.0.0 / androidx.core 1.0.0
|
||||
// RecyclerView tries to set AccessibilityDelegateCompat to null
|
||||
// but ViewCompat code replaces is with the default one. RecyclerView never
|
||||
// fetches another one from its delegate because it checks that it's set so we remove it
|
||||
// and let RecyclerView ask for a new delegate.
|
||||
itemView.setAccessibilityDelegate(null);
|
||||
}
|
||||
|
||||
|
||||
private void setContentDescription(@Nullable StatusViewData.Concrete status) {
|
||||
if (status == null) {
|
||||
itemView.setContentDescription(
|
||||
itemView.getContext().getString(R.string.load_more_placeholder_text));
|
||||
} else {
|
||||
setDescriptionForStatus(status);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private void setDescriptionForStatus(@NonNull StatusViewData.Concrete status) {
|
||||
Context context = itemView.getContext();
|
||||
|
||||
String description = context.getString(R.string.description_status,
|
||||
status.getUserFullName(),
|
||||
getContentWarningDescription(context, status),
|
||||
(!status.isSensitive() || status.isExpanded() ? status.getContent() : ""),
|
||||
getCreatedAtDescription(status.getCreatedAt()),
|
||||
getReblogDescription(context, status),
|
||||
status.getNickname(),
|
||||
status.isReblogged() ? context.getString(R.string.description_status_reblogged) : "",
|
||||
status.isFavourited() ? context.getString(R.string.description_status_favourited) : "",
|
||||
getMediaDescription(context, status),
|
||||
getVisibilityDescription(context, status.getVisibility()),
|
||||
getFavsText(context, status.getFavouritesCount()),
|
||||
getReblogsText(context, status.getReblogsCount())
|
||||
);
|
||||
itemView.setContentDescription(description);
|
||||
}
|
||||
|
||||
private CharSequence getReblogDescription(Context context,
|
||||
@NonNull StatusViewData.Concrete status) {
|
||||
CharSequence reblogDescriontion;
|
||||
String rebloggedUsername = status.getRebloggedByUsername();
|
||||
if (rebloggedUsername != null) {
|
||||
reblogDescriontion = context
|
||||
.getString(R.string.status_boosted_format, rebloggedUsername);
|
||||
} else {
|
||||
reblogDescriontion = "";
|
||||
}
|
||||
return reblogDescriontion;
|
||||
}
|
||||
|
||||
private CharSequence getMediaDescription(Context context,
|
||||
@NonNull StatusViewData.Concrete status) {
|
||||
if (status.getAttachments().isEmpty()) {
|
||||
return "";
|
||||
}
|
||||
StringBuilder mediaDescriptions = CollectionsKt.fold(
|
||||
status.getAttachments(),
|
||||
new StringBuilder(),
|
||||
(builder, a) -> {
|
||||
if (a.getDescription() == null) {
|
||||
String placeholder =
|
||||
context.getString(R.string.description_status_media_no_description_placeholder);
|
||||
return builder.append(placeholder);
|
||||
} else {
|
||||
builder.append("; ");
|
||||
return builder.append(a.getDescription());
|
||||
}
|
||||
});
|
||||
return context.getString(R.string.description_status_media, mediaDescriptions);
|
||||
}
|
||||
|
||||
private CharSequence getContentWarningDescription(Context context,
|
||||
@NonNull StatusViewData.Concrete status) {
|
||||
if (!TextUtils.isEmpty(status.getSpoilerText())) {
|
||||
return context.getString(R.string.description_status_cw, status.getSpoilerText());
|
||||
} else {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
private CharSequence getVisibilityDescription(Context context, Status.Visibility visibility) {
|
||||
int resource;
|
||||
switch (visibility) {
|
||||
case PUBLIC:
|
||||
resource = R.string.description_visiblity_public;
|
||||
break;
|
||||
case UNLISTED:
|
||||
resource = R.string.description_visiblity_unlisted;
|
||||
break;
|
||||
case PRIVATE:
|
||||
resource = R.string.description_visiblity_private;
|
||||
break;
|
||||
case DIRECT:
|
||||
resource = R.string.description_visiblity_direct;
|
||||
break;
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
return context.getString(resource);
|
||||
}
|
||||
|
||||
protected CharSequence getFavsText(Context context, int count) {
|
||||
if (count > 0) {
|
||||
String countString = numberFormat.format(count);
|
||||
return HtmlUtils.fromHtml(context.getResources().getQuantityString(R.plurals.favs, count, countString));
|
||||
} else {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
protected CharSequence getReblogsText(Context context, int count) {
|
||||
if (count > 0) {
|
||||
String countString = numberFormat.format(count);
|
||||
return HtmlUtils.fromHtml(context.getResources().getQuantityString(R.plurals.reblogs, count, countString));
|
||||
} else {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,7 +21,6 @@ import com.keylesspalace.tusky.entity.Card;
|
|||
import com.keylesspalace.tusky.entity.Status;
|
||||
import com.keylesspalace.tusky.interfaces.StatusActionListener;
|
||||
import com.keylesspalace.tusky.util.CustomURLSpan;
|
||||
import com.keylesspalace.tusky.util.HtmlUtils;
|
||||
import com.keylesspalace.tusky.util.LinkHelper;
|
||||
import com.keylesspalace.tusky.viewdata.StatusViewData;
|
||||
import com.squareup.picasso.Picasso;
|
||||
|
|
@ -44,8 +43,6 @@ class StatusDetailedViewHolder extends StatusBaseViewHolder {
|
|||
private TextView cardUrl;
|
||||
private View infoDivider;
|
||||
|
||||
private NumberFormat numberFormat = NumberFormat.getNumberInstance();
|
||||
|
||||
StatusDetailedViewHolder(View view) {
|
||||
super(view, false);
|
||||
reblogs = view.findViewById(R.id.status_reblogs);
|
||||
|
|
@ -74,36 +71,34 @@ class StatusDetailedViewHolder extends StatusBaseViewHolder {
|
|||
}
|
||||
}
|
||||
|
||||
private void setReblogAndFavCount(int reblogCount, int favCount, StatusActionListener listener) {
|
||||
private void setReblogAndFavCount(int reblogCount, int favCount, StatusActionListener listener) {
|
||||
|
||||
if(reblogCount > 0) {
|
||||
String reblogCountString = numberFormat.format(reblogCount);
|
||||
reblogs.setText(HtmlUtils.fromHtml(reblogs.getResources().getQuantityString(R.plurals.reblogs, reblogCount, reblogCountString)));
|
||||
if (reblogCount > 0) {
|
||||
reblogs.setText(getReblogsText(reblogs.getContext(), reblogCount));
|
||||
reblogs.setVisibility(View.VISIBLE);
|
||||
} else {
|
||||
reblogs.setVisibility(View.GONE);
|
||||
}
|
||||
if(favCount > 0) {
|
||||
String favCountString = numberFormat.format(favCount);
|
||||
favourites.setText(HtmlUtils.fromHtml(favourites.getResources().getQuantityString(R.plurals.favs, favCount, favCountString)));
|
||||
if (favCount > 0) {
|
||||
favourites.setText(getFavsText(favourites.getContext(), favCount));
|
||||
favourites.setVisibility(View.VISIBLE);
|
||||
} else {
|
||||
favourites.setVisibility(View.GONE);
|
||||
}
|
||||
|
||||
if(reblogs.getVisibility() == View.GONE && favourites.getVisibility() == View.GONE) {
|
||||
if (reblogs.getVisibility() == View.GONE && favourites.getVisibility() == View.GONE) {
|
||||
infoDivider.setVisibility(View.GONE);
|
||||
} else {
|
||||
infoDivider.setVisibility(View.VISIBLE);
|
||||
}
|
||||
|
||||
reblogs.setOnClickListener( v -> {
|
||||
reblogs.setOnClickListener(v -> {
|
||||
int position = getAdapterPosition();
|
||||
if (position != RecyclerView.NO_POSITION) {
|
||||
listener.onShowReblogs(position);
|
||||
}
|
||||
});
|
||||
favourites.setOnClickListener( v -> {
|
||||
favourites.setOnClickListener(v -> {
|
||||
int position = getAdapterPosition();
|
||||
if (position != RecyclerView.NO_POSITION) {
|
||||
listener.onShowFavs(position);
|
||||
|
|
@ -140,7 +135,7 @@ class StatusDetailedViewHolder extends StatusBaseViewHolder {
|
|||
|
||||
|
||||
View.OnLongClickListener longClickListener = view -> {
|
||||
TextView textView = (TextView)view;
|
||||
TextView textView = (TextView) view;
|
||||
ClipboardManager clipboard = (ClipboardManager) view.getContext().getSystemService(Context.CLIPBOARD_SERVICE);
|
||||
ClipData clip = ClipData.newPlainText("toot", textView.getText());
|
||||
clipboard.setPrimaryClip(clip);
|
||||
|
|
@ -153,7 +148,7 @@ class StatusDetailedViewHolder extends StatusBaseViewHolder {
|
|||
content.setOnLongClickListener(longClickListener);
|
||||
contentWarningDescription.setOnLongClickListener(longClickListener);
|
||||
|
||||
if(status.getAttachments().size() == 0 && status.getCard() != null && !TextUtils.isEmpty(status.getCard().getUrl())) {
|
||||
if (status.getAttachments().size() == 0 && status.getCard() != null && !TextUtils.isEmpty(status.getCard().getUrl())) {
|
||||
final Card card = status.getCard();
|
||||
cardView.setVisibility(View.VISIBLE);
|
||||
cardTitle.setText(card.getTitle());
|
||||
|
|
@ -161,10 +156,10 @@ class StatusDetailedViewHolder extends StatusBaseViewHolder {
|
|||
|
||||
cardUrl.setText(card.getUrl());
|
||||
|
||||
if(card.getWidth() > 0 && card.getHeight() > 0 && !TextUtils.isEmpty(card.getImage())) {
|
||||
if (card.getWidth() > 0 && card.getHeight() > 0 && !TextUtils.isEmpty(card.getImage())) {
|
||||
cardImage.setVisibility(View.VISIBLE);
|
||||
|
||||
if(card.getWidth() > card.getHeight()) {
|
||||
if (card.getWidth() > card.getHeight()) {
|
||||
cardView.setOrientation(LinearLayout.VERTICAL);
|
||||
cardImage.getLayoutParams().height = cardImage.getContext().getResources()
|
||||
.getDimensionPixelSize(R.dimen.card_image_vertical_height);
|
||||
|
|
|
|||
|
|
@ -16,8 +16,6 @@
|
|||
package com.keylesspalace.tusky.adapter;
|
||||
|
||||
import android.content.Context;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import android.text.InputFilter;
|
||||
import android.view.View;
|
||||
import android.widget.ImageView;
|
||||
|
|
@ -30,6 +28,7 @@ import com.keylesspalace.tusky.util.SmartLengthInputFilter;
|
|||
import com.keylesspalace.tusky.viewdata.StatusViewData;
|
||||
import com.squareup.picasso.Picasso;
|
||||
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.recyclerview.widget.RecyclerView;
|
||||
import at.connyduck.sparkbutton.helpers.Utils;
|
||||
|
||||
|
|
@ -92,9 +91,7 @@ public class StatusViewHolder extends StatusBaseViewHolder {
|
|||
}
|
||||
|
||||
rebloggedBar.setOnClickListener(v -> listener.onOpenReblog(getAdapterPosition()));
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private void setRebloggedByDisplayName(final String name) {
|
||||
|
|
@ -133,4 +130,4 @@ public class StatusViewHolder extends StatusBaseViewHolder {
|
|||
content.setFilters(NO_INPUT_FILTER);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue