From a9524508e6db87a79aeac62968a0886984e03fd6 Mon Sep 17 00:00:00 2001 From: Ivan Kupalov Date: Mon, 4 Mar 2019 19:24:27 +0100 Subject: [PATCH] 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 * 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 --- .../tusky/adapter/StatusBaseViewHolder.java | 200 ++++++++++-- .../adapter/StatusDetailedViewHolder.java | 29 +- .../tusky/adapter/StatusViewHolder.java | 7 +- .../conversation/ConversationsFragment.kt | 2 +- .../tusky/fragment/NotificationsFragment.java | 11 + .../tusky/fragment/SearchFragment.kt | 2 +- .../tusky/fragment/TimelineFragment.java | 23 +- .../tusky/fragment/ViewThreadFragment.java | 29 +- .../interfaces/StatusActionListener.java | 8 +- .../util/ListStatusAccessibilityDelegate.kt | 297 ++++++++++++++++++ .../tusky/viewdata/StatusViewData.java | 8 +- app/src/main/res/layout/item_status.xml | 18 ++ .../main/res/layout/item_status_detailed.xml | 16 + app/src/main/res/values/actions.xml | 23 ++ app/src/main/res/values/strings.xml | 45 +++ 15 files changed, 643 insertions(+), 75 deletions(-) create mode 100644 app/src/main/java/com/keylesspalace/tusky/util/ListStatusAccessibilityDelegate.kt create mode 100644 app/src/main/res/values/actions.xml diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/StatusBaseViewHolder.java b/app/src/main/java/com/keylesspalace/tusky/adapter/StatusBaseViewHolder.java index 313d4671..0fd5f618 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/StatusBaseViewHolder.java +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/StatusBaseViewHolder.java @@ -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 emojis, + @Nullable Status.Mention[] mentions, + @NonNull List 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; + timestampInfo.setText(getAbsoluteTime(createdAt)); + } else { + String readout; if (createdAt != null) { - if (System.currentTimeMillis() - createdAt.getTime() > 86400000L) { - time = longSdf.format(createdAt); - } else { - time = shortSdf.format(createdAt); - } + long then = createdAt.getTime(); + long now = new Date().getTime(); + readout = DateUtils.getRelativeTimeSpanString(timestampInfo.getContext(), then, now); } else { - time = "??:??:??"; + // 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); } - timestampInfo.setText(time); } else { - // This is the visible timestampInfo. - String readout; + 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. */ - 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, + 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 ""; + } } } diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/StatusDetailedViewHolder.java b/app/src/main/java/com/keylesspalace/tusky/adapter/StatusDetailedViewHolder.java index 4fa2f01c..5d31aae6 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/StatusDetailedViewHolder.java +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/StatusDetailedViewHolder.java @@ -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); diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/StatusViewHolder.java b/app/src/main/java/com/keylesspalace/tusky/adapter/StatusViewHolder.java index 895005e9..df708d94 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/StatusViewHolder.java +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/StatusViewHolder.java @@ -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); } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsFragment.kt index edbf4a3f..2cda3a96 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsFragment.kt @@ -122,7 +122,7 @@ class ConversationsFragment : SFragment(), StatusActionListener, Injectable { } } - override fun onViewMedia(position: Int, attachmentIndex: Int, view: View) { + override fun onViewMedia(position: Int, attachmentIndex: Int, view: View?) { viewModel.conversations.value?.getOrNull(position)?.lastStatus?.let { viewMedia(attachmentIndex, it.toStatus(), view) } diff --git a/app/src/main/java/com/keylesspalace/tusky/fragment/NotificationsFragment.java b/app/src/main/java/com/keylesspalace/tusky/fragment/NotificationsFragment.java index 2e8f7674..43c8b6d8 100644 --- a/app/src/main/java/com/keylesspalace/tusky/fragment/NotificationsFragment.java +++ b/app/src/main/java/com/keylesspalace/tusky/fragment/NotificationsFragment.java @@ -47,6 +47,7 @@ import com.keylesspalace.tusky.network.TimelineCases; import com.keylesspalace.tusky.util.CollectionUtil; import com.keylesspalace.tusky.util.Either; import com.keylesspalace.tusky.util.HttpHeaderLink; +import com.keylesspalace.tusky.util.ListStatusAccessibilityDelegate; import com.keylesspalace.tusky.util.ListUtils; import com.keylesspalace.tusky.util.PairedList; import com.keylesspalace.tusky.util.ThemeUtils; @@ -186,6 +187,16 @@ public class NotificationsFragment extends SFragment implements recyclerView.setHasFixedSize(true); layoutManager = new LinearLayoutManager(context); recyclerView.setLayoutManager(layoutManager); + recyclerView.setAccessibilityDelegateCompat( + new ListStatusAccessibilityDelegate(recyclerView, this, (pos) -> { + NotificationViewData notification = notifications.getPairedItem(pos); + // We support replies only for now + if (notification instanceof NotificationViewData.Concrete) { + return ((NotificationViewData.Concrete) notification).getStatusViewData(); + } else { + return null; + } + })); recyclerView.addItemDecoration(new DividerItemDecoration(context, DividerItemDecoration.VERTICAL)); diff --git a/app/src/main/java/com/keylesspalace/tusky/fragment/SearchFragment.kt b/app/src/main/java/com/keylesspalace/tusky/fragment/SearchFragment.kt index d6d6a21c..9f772b6c 100644 --- a/app/src/main/java/com/keylesspalace/tusky/fragment/SearchFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/fragment/SearchFragment.kt @@ -189,7 +189,7 @@ class SearchFragment : SFragment(), StatusActionListener, Injectable { } } - override fun onViewMedia(position: Int, attachmentIndex: Int, view: View) { + override fun onViewMedia(position: Int, attachmentIndex: Int, view: View?) { val status = searchAdapter.getStatusAtPosition(position) ?: return viewMedia(attachmentIndex, status, view) } diff --git a/app/src/main/java/com/keylesspalace/tusky/fragment/TimelineFragment.java b/app/src/main/java/com/keylesspalace/tusky/fragment/TimelineFragment.java index 5f2de992..7ab89c7c 100644 --- a/app/src/main/java/com/keylesspalace/tusky/fragment/TimelineFragment.java +++ b/app/src/main/java/com/keylesspalace/tusky/fragment/TimelineFragment.java @@ -16,6 +16,7 @@ package com.keylesspalace.tusky.fragment; import android.content.Context; +import android.content.Intent; import android.content.SharedPreferences; import android.os.Bundle; import android.preference.PreferenceManager; @@ -27,6 +28,8 @@ import android.widget.ProgressBar; import com.google.android.material.floatingactionbutton.FloatingActionButton; import com.google.android.material.tabs.TabLayout; +import com.keylesspalace.tusky.AccountListActivity; +import com.keylesspalace.tusky.BaseActivity; import com.keylesspalace.tusky.R; import com.keylesspalace.tusky.adapter.TimelineAdapter; import com.keylesspalace.tusky.appstore.BlockEvent; @@ -50,6 +53,7 @@ import com.keylesspalace.tusky.repository.TimelineRepository; import com.keylesspalace.tusky.repository.TimelineRequestMode; import com.keylesspalace.tusky.util.CollectionUtil; import com.keylesspalace.tusky.util.Either; +import com.keylesspalace.tusky.util.ListStatusAccessibilityDelegate; import com.keylesspalace.tusky.util.ListUtils; import com.keylesspalace.tusky.util.PairedList; import com.keylesspalace.tusky.util.StringUtils; @@ -347,6 +351,8 @@ public class TimelineFragment extends SFragment implements } private void setupRecyclerView() { + recyclerView.setAccessibilityDelegateCompat( + new ListStatusAccessibilityDelegate(recyclerView, this, statuses::getPairedItem)); Context context = recyclerView.getContext(); recyclerView.setHasFixedSize(true); layoutManager = new LinearLayoutManager(context); @@ -630,6 +636,21 @@ public class TimelineFragment extends SFragment implements updateAdapter(); } + + @Override + public void onShowReblogs(int position) { + String statusId = statuses.get(position).asRight().getId(); + Intent intent = AccountListActivity.newIntent(getContext(), AccountListActivity.Type.REBLOGGED, statusId); + ((BaseActivity) getActivity()).startActivityWithSlideInAnimation(intent); + } + + @Override + public void onShowFavs(int position) { + String statusId = statuses.get(position).asRight().getId(); + Intent intent = AccountListActivity.newIntent(getContext(), AccountListActivity.Type.FAVOURITED, statusId); + ((BaseActivity) getActivity()).startActivityWithSlideInAnimation(intent); + } + @Override public void onLoadMore(int position) { //check bounds before accessing list, @@ -684,7 +705,7 @@ public class TimelineFragment extends SFragment implements } @Override - public void onViewMedia(int position, int attachmentIndex, @NonNull View view) { + public void onViewMedia(int position, int attachmentIndex, @Nullable View view) { Status status = statuses.get(position).asRightOrNull(); if (status == null) return; super.viewMedia(attachmentIndex, status, view); diff --git a/app/src/main/java/com/keylesspalace/tusky/fragment/ViewThreadFragment.java b/app/src/main/java/com/keylesspalace/tusky/fragment/ViewThreadFragment.java index 500530c8..360549ea 100644 --- a/app/src/main/java/com/keylesspalace/tusky/fragment/ViewThreadFragment.java +++ b/app/src/main/java/com/keylesspalace/tusky/fragment/ViewThreadFragment.java @@ -15,37 +15,27 @@ package com.keylesspalace.tusky.fragment; -import androidx.arch.core.util.Function; -import androidx.lifecycle.Lifecycle; import android.content.Context; import android.content.Intent; import android.content.SharedPreferences; import android.graphics.drawable.Drawable; import android.os.Bundle; import android.preference.PreferenceManager; -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import com.google.android.material.snackbar.Snackbar; -import androidx.core.util.Pair; -import androidx.swiperefreshlayout.widget.SwipeRefreshLayout; -import androidx.recyclerview.widget.DividerItemDecoration; -import androidx.recyclerview.widget.LinearLayoutManager; -import androidx.recyclerview.widget.RecyclerView; -import androidx.recyclerview.widget.SimpleItemAnimator; import android.text.TextUtils; import android.util.Log; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; +import com.google.android.material.snackbar.Snackbar; import com.keylesspalace.tusky.AccountListActivity; import com.keylesspalace.tusky.BaseActivity; import com.keylesspalace.tusky.BuildConfig; import com.keylesspalace.tusky.R; import com.keylesspalace.tusky.ViewThreadActivity; import com.keylesspalace.tusky.adapter.ThreadAdapter; -import com.keylesspalace.tusky.appstore.EventHub; import com.keylesspalace.tusky.appstore.BlockEvent; +import com.keylesspalace.tusky.appstore.EventHub; import com.keylesspalace.tusky.appstore.FavoriteEvent; import com.keylesspalace.tusky.appstore.ReblogEvent; import com.keylesspalace.tusky.appstore.StatusComposedEvent; @@ -57,6 +47,7 @@ import com.keylesspalace.tusky.entity.StatusContext; import com.keylesspalace.tusky.interfaces.StatusActionListener; import com.keylesspalace.tusky.network.MastodonApi; import com.keylesspalace.tusky.network.TimelineCases; +import com.keylesspalace.tusky.util.ListStatusAccessibilityDelegate; import com.keylesspalace.tusky.util.PairedList; import com.keylesspalace.tusky.util.SmartLengthInputFilter; import com.keylesspalace.tusky.util.ThemeUtils; @@ -70,6 +61,16 @@ import java.util.Locale; import javax.inject.Inject; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.arch.core.util.Function; +import androidx.core.util.Pair; +import androidx.lifecycle.Lifecycle; +import androidx.recyclerview.widget.DividerItemDecoration; +import androidx.recyclerview.widget.LinearLayoutManager; +import androidx.recyclerview.widget.RecyclerView; +import androidx.recyclerview.widget.SimpleItemAnimator; +import androidx.swiperefreshlayout.widget.SwipeRefreshLayout; import io.reactivex.android.schedulers.AndroidSchedulers; import retrofit2.Call; import retrofit2.Callback; @@ -147,6 +148,8 @@ public final class ViewThreadFragment extends SFragment implements recyclerView.setHasFixedSize(true); LinearLayoutManager layoutManager = new LinearLayoutManager(context); recyclerView.setLayoutManager(layoutManager); + recyclerView.setAccessibilityDelegateCompat( + new ListStatusAccessibilityDelegate(recyclerView, this, statuses::getPairedItem)); DividerItemDecoration divider = new DividerItemDecoration( context, layoutManager.getOrientation()); recyclerView.addItemDecoration(divider); @@ -264,7 +267,7 @@ public final class ViewThreadFragment extends SFragment implements } private void updateStatus(int position, Status status) { - if(position >= 0 && position < statuses.size()) { + if (position >= 0 && position < statuses.size()) { Status actionableStatus = status.getActionableStatus(); diff --git a/app/src/main/java/com/keylesspalace/tusky/interfaces/StatusActionListener.java b/app/src/main/java/com/keylesspalace/tusky/interfaces/StatusActionListener.java index 31e6230f..480a146a 100644 --- a/app/src/main/java/com/keylesspalace/tusky/interfaces/StatusActionListener.java +++ b/app/src/main/java/com/keylesspalace/tusky/interfaces/StatusActionListener.java @@ -18,14 +18,20 @@ package com.keylesspalace.tusky.interfaces; import android.view.View; import androidx.annotation.NonNull; +import androidx.annotation.Nullable; public interface StatusActionListener extends LinkListener { void onReply(int position); void onReblog(final boolean reblog, final int position); void onFavourite(final boolean favourite, final int position); void onMore(@NonNull View view, final int position); - void onViewMedia(int position, int attachmentIndex, @NonNull View view); + void onViewMedia(int position, int attachmentIndex, @Nullable View view); void onViewThread(int position); + + /** + * Open reblog author for the status. + * @param position At which position in the list status is located + */ void onOpenReblog(int position); void onExpandedChange(boolean expanded, int position); void onContentHiddenChange(boolean isShowing, int position); diff --git a/app/src/main/java/com/keylesspalace/tusky/util/ListStatusAccessibilityDelegate.kt b/app/src/main/java/com/keylesspalace/tusky/util/ListStatusAccessibilityDelegate.kt new file mode 100644 index 00000000..e614b78c --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/util/ListStatusAccessibilityDelegate.kt @@ -0,0 +1,297 @@ +package com.keylesspalace.tusky.util + +import android.content.Context +import android.os.Bundle +import android.text.Spannable +import android.text.style.URLSpan +import android.view.View +import android.view.accessibility.AccessibilityEvent +import android.view.accessibility.AccessibilityManager +import android.widget.ArrayAdapter +import androidx.appcompat.app.AlertDialog +import androidx.core.view.AccessibilityDelegateCompat +import androidx.core.view.accessibility.AccessibilityNodeInfoCompat +import androidx.core.view.accessibility.AccessibilityNodeInfoCompat.AccessibilityActionCompat +import androidx.recyclerview.widget.RecyclerView +import androidx.recyclerview.widget.RecyclerViewAccessibilityDelegate +import com.keylesspalace.tusky.R +import com.keylesspalace.tusky.interfaces.StatusActionListener +import com.keylesspalace.tusky.viewdata.StatusViewData + +// Not using lambdas because there's boxing of int then +interface StatusProvider { + fun getStatus(pos: Int): StatusViewData? +} + +class ListStatusAccessibilityDelegate( + private val recyclerView: RecyclerView, + private val statusActionListener: StatusActionListener, + private val statusProvider: StatusProvider +) : RecyclerViewAccessibilityDelegate(recyclerView) { + private val a11yManager = context.getSystemService(Context.ACCESSIBILITY_SERVICE) + as AccessibilityManager + + override fun getItemDelegate(): AccessibilityDelegateCompat = itemDelegate + + private val context: Context get() = recyclerView.context + + private val itemDelegate = object : RecyclerViewAccessibilityDelegate.ItemDelegate(this) { + override fun onInitializeAccessibilityNodeInfo(host: View, + info: AccessibilityNodeInfoCompat) { + super.onInitializeAccessibilityNodeInfo(host, info) + + val pos = recyclerView.getChildAdapterPosition(host) + val status = statusProvider.getStatus(pos) ?: return + if (status is StatusViewData.Concrete) { + if (!status.spoilerText.isNullOrEmpty()) { + info.addAction(if (status.isExpanded) collapseCwAction else expandCwAction) + } + + info.addAction(replyAction) + + if (status.rebloggingEnabled) { + info.addAction(if (status.isReblogged) unreblogAction else reblogAction) + } + info.addAction(if (status.isFavourited) unfavouriteAction else favouriteAction) + + val mediaActions = intArrayOf( + R.id.action_open_media_1, + R.id.action_open_media_2, + R.id.action_open_media_3, + R.id.action_open_media_4) + for (i in 0 until status.attachments.size) { + info.addAction(AccessibilityActionCompat( + mediaActions[i], + context.getString(R.string.action_open_media_n, i + 1))) + } + + info.addAction(openProfileAction) + if (getLinks(status).any()) info.addAction(linksAction) + + val mentions = status.mentions + if (mentions != null && mentions.isNotEmpty()) info.addAction(mentionsAction) + + if (getHashtags(status).any()) info.addAction(hashtagsAction) + if (!status.rebloggedByUsername.isNullOrEmpty()) { + info.addAction(openRebloggerAction) + } + if (status.reblogsCount > 0) info.addAction(openRebloggedByAction) + if (status.favouritesCount > 0) info.addAction(openFavsAction) + } + + } + + override fun performAccessibilityAction(host: View, action: Int, + args: Bundle?): Boolean { + val pos = recyclerView.getChildAdapterPosition(host) + when (action) { + R.id.action_reply -> { + interrupt() + statusActionListener.onReply(pos) + } + R.id.action_favourite -> statusActionListener.onFavourite(true, pos) + R.id.action_unfavourite -> statusActionListener.onFavourite(false, pos) + R.id.action_reblog -> statusActionListener.onReblog(true, pos) + R.id.action_unreblog -> statusActionListener.onReblog(false, pos) + R.id.action_open_profile -> { + interrupt() + statusActionListener.onViewAccount( + (statusProvider.getStatus(pos) as StatusViewData.Concrete).senderId) + } + R.id.action_open_media_1 -> { + interrupt() + statusActionListener.onViewMedia(pos, 0, null) + } + R.id.action_open_media_2 -> { + interrupt() + statusActionListener.onViewMedia(pos, 1, null) + } + R.id.action_open_media_3 -> { + interrupt() + statusActionListener.onViewMedia(pos, 2, null) + } + R.id.action_open_media_4 -> { + interrupt() + statusActionListener.onViewMedia(pos, 3, null) + } + R.id.action_expand_cw -> { + statusActionListener.onExpandedChange(true, pos) + // Stop and restart narrator before it reads old description. + // Would be nice if we could *just* read the content here but doesn't seem + // to be possible. + forceFocus(host) + } + R.id.action_collapse_cw -> { + statusActionListener.onExpandedChange(false, pos) + interrupt() + } + R.id.action_links -> showLinksDialog(host) + R.id.action_mentions -> showMentionsDialog(host) + R.id.action_hashtags -> showHashtagsDialog(host) + R.id.action_open_reblogger -> { + interrupt() + statusActionListener.onOpenReblog(pos) + } + R.id.action_open_reblogged_by -> { + interrupt() + statusActionListener.onShowReblogs(pos) + } + R.id.action_open_faved_by -> { + interrupt() + statusActionListener.onShowFavs(pos) + } + else -> return super.performAccessibilityAction(host, action, args) + } + return true + } + + + private fun showLinksDialog(host: View) { + val status = getStatus(host) as? StatusViewData.Concrete ?: return + val links = getLinks(status).toList() + val textLinks = links.map { item -> item.link } + AlertDialog.Builder(host.context) + .setTitle(R.string.title_links_dialog) + .setAdapter(ArrayAdapter( + host.context, + android.R.layout.simple_list_item_1, + textLinks) + ) { _, which -> LinkHelper.openLink(links[which].link, host.context) } + .show() + .let { forceFocus(it.listView) } + } + + private fun showMentionsDialog(host: View) { + val status = getStatus(host) as? StatusViewData.Concrete ?: return + val mentions = status.mentions ?: return + val stringMentions = mentions.map { it.username } + AlertDialog.Builder(host.context) + .setTitle(R.string.title_mentions_dialog) + .setAdapter(ArrayAdapter(host.context, + android.R.layout.simple_list_item_1, stringMentions) + ) { _, which -> + statusActionListener.onViewAccount(mentions[which].id) + } + .show() + .let { forceFocus(it.listView) } + } + + private fun showHashtagsDialog(host: View) { + val status = getStatus(host) as? StatusViewData.Concrete ?: return + val tags = getHashtags(status).map { it.subSequence(1, it.length) }.toList() + AlertDialog.Builder(host.context) + .setTitle(R.string.title_hashtags_dialog) + .setAdapter(ArrayAdapter(host.context, + android.R.layout.simple_list_item_1, tags) + ) { _, which -> + statusActionListener.onViewTag(tags[which].toString()) + } + .show() + .let { forceFocus(it.listView) } + } + + private fun getStatus(childView: View): StatusViewData { + return statusProvider.getStatus(recyclerView.getChildAdapterPosition(childView))!! + } + } + + + private fun getLinks(status: StatusViewData.Concrete): Sequence { + val content = status.content + return if (content is Spannable) { + content.getSpans(0, content.length, URLSpan::class.java) + .asSequence() + .map { span -> + val text = content.subSequence( + content.getSpanStart(span), + content.getSpanEnd(span)) + if (isHashtag(text)) null else LinkSpanInfo(text.toString(), span.url) + } + .filterNotNull() + } else { + emptySequence() + } + } + + private fun getHashtags(status: StatusViewData.Concrete): Sequence { + val content = status.content + return content.getSpans(0, content.length, Object::class.java) + .asSequence() + .map { span -> + content.subSequence(content.getSpanStart(span), content.getSpanEnd(span)) + } + .filter(this::isHashtag) + } + + private fun forceFocus(host: View) { + interrupt() + host.post { + host.sendAccessibilityEvent(AccessibilityEvent.TYPE_VIEW_ACCESSIBILITY_FOCUSED) + } + } + + private fun interrupt() { + a11yManager.interrupt() + } + + + private fun isHashtag(text: CharSequence) = text.startsWith("#") + + private val collapseCwAction = AccessibilityActionCompat( + R.id.action_collapse_cw, + context.getString(R.string.status_content_warning_show_less)) + + private val expandCwAction = AccessibilityActionCompat( + R.id.action_expand_cw, + context.getString(R.string.status_content_warning_show_more)) + + private val replyAction = AccessibilityActionCompat( + R.id.action_reply, + context.getString(R.string.action_reply)) + + private val unreblogAction = AccessibilityActionCompat( + R.id.action_unreblog, + context.getString(R.string.action_unreblog)) + + private val reblogAction = AccessibilityActionCompat( + R.id.action_reblog, + context.getString(R.string.action_reblog)) + + private val unfavouriteAction = AccessibilityActionCompat( + R.id.action_unfavourite, + context.getString(R.string.action_unfavourite)) + + private val favouriteAction = AccessibilityActionCompat( + R.id.action_favourite, + context.getString(R.string.action_favourite)) + + private val openProfileAction = AccessibilityActionCompat( + R.id.action_open_profile, + context.getString(R.string.action_view_profile)) + + private val linksAction = AccessibilityActionCompat( + R.id.action_links, + context.getString(R.string.action_links)) + + private val mentionsAction = AccessibilityActionCompat( + R.id.action_mentions, + context.getString(R.string.action_mentions)) + + private val hashtagsAction = AccessibilityActionCompat( + R.id.action_hashtags, + context.getString(R.string.action_hashtags)) + + private val openRebloggerAction = AccessibilityActionCompat( + R.id.action_open_reblogger, + context.getString(R.string.action_open_reblogger)) + + private val openRebloggedByAction = AccessibilityActionCompat( + R.id.action_open_reblogged_by, + context.getString(R.string.action_open_reblogged_by)) + + private val openFavsAction = AccessibilityActionCompat( + R.id.action_open_faved_by, + context.getString(R.string.action_open_faved_by)) + + private data class LinkSpanInfo(val text: String, val link: String) +} \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/viewdata/StatusViewData.java b/app/src/main/java/com/keylesspalace/tusky/viewdata/StatusViewData.java index 7f0489e2..abe99495 100644 --- a/app/src/main/java/com/keylesspalace/tusky/viewdata/StatusViewData.java +++ b/app/src/main/java/com/keylesspalace/tusky/viewdata/StatusViewData.java @@ -54,8 +54,8 @@ public abstract class StatusViewData { private final String id; private final Spanned content; - private final boolean reblogged; - private final boolean favourited; + final boolean reblogged; + final boolean favourited; @Nullable private final String spoilerText; private final Status.Visibility visibility; @@ -65,7 +65,7 @@ public abstract class StatusViewData { @Nullable private final String rebloggedAvatar; private final boolean isSensitive; - private final boolean isExpanded; + final boolean isExpanded; private final boolean isShowingContent; private final String userFullName; private final String nickname; @@ -86,7 +86,7 @@ public abstract class StatusViewData { @Nullable private final Card card; private final boolean isCollapsible; /** Whether the status meets the requirement to be collapse */ - private final boolean isCollapsed; /** Whether the status is shown partially or fully */ + final boolean isCollapsed; /** Whether the status is shown partially or fully */ public Concrete(String id, Spanned content, boolean reblogged, boolean favourited, @Nullable String spoilerText, Status.Visibility visibility, List attachments, diff --git a/app/src/main/res/layout/item_status.xml b/app/src/main/res/layout/item_status.xml index 01124992..94cf3127 100644 --- a/app/src/main/res/layout/item_status.xml +++ b/app/src/main/res/layout/item_status.xml @@ -8,6 +8,7 @@ android:layout_height="wrap_content" android:clipChildren="false" android:clipToPadding="false" + android:focusable="true" android:paddingLeft="14dp" android:paddingRight="14dp"> @@ -19,6 +20,7 @@ android:drawableStart="?attr/status_reblog_small_drawable" android:drawablePadding="6dp" android:gravity="center_vertical" + android:importantForAccessibility="no" android:paddingStart="38dp" android:textColor="?android:textColorTertiary" android:textSize="?attr/status_text_medium" @@ -35,6 +37,7 @@ android:layout_height="48dp" android:layout_marginTop="14dp" android:contentDescription="@string/action_view_profile" + android:importantForAccessibility="no" android:scaleType="centerCrop" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toBottomOf="@id/status_reblogged" @@ -45,6 +48,7 @@ android:layout_width="24dp" android:layout_height="24dp" android:contentDescription="@null" + android:importantForAccessibility="no" android:visibility="gone" app:layout_constraintBottom_toBottomOf="@id/status_avatar" app:layout_constraintEnd_toEndOf="@id/status_avatar" @@ -58,6 +62,7 @@ android:layout_marginStart="14dp" android:layout_marginTop="10dp" android:ellipsize="end" + android:importantForAccessibility="no" android:maxLines="1" android:paddingEnd="@dimen/status_display_name_padding_end" android:textColor="?android:textColorPrimary" @@ -75,6 +80,7 @@ android:layout_width="0dp" android:layout_height="wrap_content" android:ellipsize="end" + android:importantForAccessibility="no" android:maxLines="1" android:textColor="?android:textColorSecondary" android:textSize="?attr/status_text_medium" @@ -88,6 +94,7 @@ android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginStart="4dp" + android:importantForAccessibility="no" android:textColor="?android:textColorSecondary" android:textSize="?attr/status_text_medium" app:layout_constraintEnd_toEndOf="parent" @@ -98,6 +105,7 @@ android:id="@+id/status_content_warning_description" android:layout_width="0dp" android:layout_height="wrap_content" + android:importantForAccessibility="no" android:lineSpacingMultiplier="1.1" android:textColor="?android:textColorPrimary" android:textSize="?attr/status_text_medium" @@ -115,6 +123,7 @@ android:layout_marginTop="4dp" android:layout_marginBottom="4dp" android:background="?attr/content_warning_button" + android:importantForAccessibility="no" android:minWidth="150dp" android:minHeight="0dp" android:paddingLeft="16dp" @@ -136,6 +145,7 @@ android:layout_height="wrap_content" android:layout_marginTop="8dp" android:focusable="true" + android:importantForAccessibility="no" android:lineSpacingMultiplier="1.1" android:textColor="?android:textColorPrimary" android:textSize="?attr/status_text_medium" @@ -151,6 +161,7 @@ android:layout_marginTop="4dp" android:layout_marginBottom="4dp" android:background="?attr/content_warning_button" + android:importantForAccessibility="no" android:minWidth="150dp" android:minHeight="0dp" android:paddingLeft="16dp" @@ -171,6 +182,7 @@ android:layout_width="0dp" android:layout_height="wrap_content" android:layout_marginTop="@dimen/status_media_preview_margin_top" + android:importantForAccessibility="noHideDescendants" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="@id/status_display_name" app:layout_constraintTop_toBottomOf="@id/button_toggle_content" @@ -287,6 +299,7 @@ android:layout_height="0dp" android:background="?attr/sensitive_media_warning_background_color" android:gravity="center" + android:importantForAccessibility="no" android:lineSpacingMultiplier="1.2" android:orientation="vertical" android:padding="8dp" @@ -307,6 +320,7 @@ android:background="?attr/selectableItemBackground" android:drawablePadding="4dp" android:gravity="center_vertical" + android:importantForAccessibility="no" android:textSize="?attr/status_text_medium" android:visibility="gone" app:layout_constraintStart_toStartOf="parent" @@ -322,6 +336,7 @@ android:layout_marginTop="4dp" android:layout_marginBottom="4dp" android:contentDescription="@string/action_reply" + android:importantForAccessibility="no" android:padding="4dp" app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintEnd_toStartOf="@id/status_reblog" @@ -336,6 +351,7 @@ android:layout_height="30dp" android:clipToPadding="false" android:contentDescription="@string/action_reblog" + android:importantForAccessibility="no" android:padding="4dp" app:layout_constraintEnd_toStartOf="@id/status_favourite" app:layout_constraintStart_toEndOf="@id/status_reply" @@ -352,6 +368,7 @@ android:layout_height="30dp" android:clipToPadding="false" android:contentDescription="@string/action_favourite" + android:importantForAccessibility="no" android:padding="4dp" app:layout_constraintEnd_toStartOf="@id/status_more" app:layout_constraintStart_toEndOf="@id/status_reblog" @@ -369,6 +386,7 @@ android:layout_height="30dp" android:layout_marginEnd="8dp" android:contentDescription="@string/action_more" + android:importantForAccessibility="no" android:padding="4dp" app:layout_constraintBottom_toBottomOf="@id/status_reply" app:layout_constraintEnd_toEndOf="parent" diff --git a/app/src/main/res/layout/item_status_detailed.xml b/app/src/main/res/layout/item_status_detailed.xml index 43fe6b9e..2ad5b788 100644 --- a/app/src/main/res/layout/item_status_detailed.xml +++ b/app/src/main/res/layout/item_status_detailed.xml @@ -18,6 +18,7 @@ android:layout_marginTop="14dp" android:layout_marginEnd="14dp" android:contentDescription="@string/action_view_profile" + android:importantForAccessibility="no" android:scaleType="centerCrop" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="parent" @@ -31,6 +32,7 @@ android:layout_marginTop="10dp" android:layout_marginEnd="14dp" android:ellipsize="end" + android:importantForAccessibility="no" android:maxLines="1" android:textColor="?android:textColorPrimary" android:textSize="?attr/status_text_medium" @@ -53,6 +55,7 @@ android:layout_marginTop="4dp" android:layout_marginEnd="14dp" android:ellipsize="end" + android:importantForAccessibility="no" android:maxLines="1" android:textColor="?android:textColorSecondary" android:textSize="?attr/status_text_medium" @@ -69,6 +72,7 @@ android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginTop="8dp" + android:importantForAccessibility="no" android:lineSpacingMultiplier="1.1" android:textColor="?android:textColorPrimary" android:textSize="?attr/status_text_large" @@ -83,6 +87,7 @@ android:layout_marginTop="4dp" android:layout_marginBottom="4dp" android:background="?attr/content_warning_button" + android:importantForAccessibility="no" android:minWidth="160dp" android:minHeight="0dp" android:paddingLeft="16dp" @@ -103,6 +108,7 @@ android:layout_marginTop="4dp" android:layout_marginBottom="4dp" android:focusable="true" + android:importantForAccessibility="no" android:lineSpacingMultiplier="1.1" android:textColor="?android:textColorPrimary" android:textSize="?attr/status_text_large" @@ -180,6 +186,7 @@ android:layout_height="wrap_content" android:layout_marginTop="12dp" android:layout_marginBottom="4dp" + android:importantForAccessibility="noHideDescendants" app:layout_constraintTop_toBottomOf="@id/card_view"> + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index d1a5df47..168a8d05 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -67,7 +67,9 @@ Quick Reply Reply Boost + Remove boost Favourite + Remove favourite More Compose Login with Mastodon @@ -114,6 +116,17 @@ Content warning Emoji keyboard Add Tab + Links + Mentions + Hashtags + Open boost author + Show boosts + Show favourites + + Hashtags + Mentions + Links + Open media #%d Downloading %1$s @@ -387,4 +400,36 @@ maximum of %1$d tabs reached + + Media: %s + + + Content warning: %s + + + No description + + + Reblogged + + + Favourited + + + Public + + + Unlisted + + + Followers + + + Direct + + + + %s; %s; %s, %s, %s; %s, %s, %s, %s; %s, %s, %s + +