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) {
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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));
|
||||
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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();
|
||||
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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<String>(
|
||||
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<CharSequence>(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<CharSequence>(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<LinkSpanInfo> {
|
||||
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<CharSequence> {
|
||||
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)
|
||||
}
|
|
@ -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<Attachment> attachments,
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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">
|
||||
|
||||
<com.keylesspalace.tusky.view.MediaPreviewImageView
|
||||
|
@ -326,6 +333,7 @@
|
|||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="10dp"
|
||||
android:drawablePadding="4dp"
|
||||
android:importantForAccessibility="no"
|
||||
android:textColor="?android:textColorTertiary"
|
||||
android:textSize="?attr/status_text_medium"
|
||||
app:layout_constraintLeft_toLeftOf="parent"
|
||||
|
@ -339,6 +347,7 @@
|
|||
android:layout_height="1dp"
|
||||
android:layout_below="@id/status_timestamp_info"
|
||||
android:layout_marginTop="6dp"
|
||||
android:importantForAccessibility="no"
|
||||
android:background="?android:attr/listDivider"
|
||||
android:paddingStart="16dp"
|
||||
android:paddingEnd="16dp"
|
||||
|
@ -350,6 +359,7 @@
|
|||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="6dp"
|
||||
android:background="?attr/selectableItemBackground"
|
||||
android:importantForAccessibility="no"
|
||||
android:padding="4dp"
|
||||
android:textSize="?attr/status_text_medium"
|
||||
app:layout_constraintStart_toStartOf="@id/status_info_divider"
|
||||
|
@ -364,6 +374,7 @@
|
|||
android:layout_marginStart="12dp"
|
||||
android:layout_marginTop="6dp"
|
||||
android:background="?attr/selectableItemBackground"
|
||||
android:importantForAccessibility="no"
|
||||
android:padding="4dp"
|
||||
android:textSize="?attr/status_text_medium"
|
||||
app:layout_constraintStart_toEndOf="@id/status_reblogs"
|
||||
|
@ -384,6 +395,7 @@
|
|||
android:layout_width="match_parent"
|
||||
android:layout_height="1dp"
|
||||
android:layout_marginTop="6dp"
|
||||
android:importantForAccessibility="no"
|
||||
android:background="?android:attr/listDivider"
|
||||
android:paddingStart="16dp"
|
||||
android:paddingEnd="16dp"
|
||||
|
@ -397,6 +409,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"
|
||||
|
@ -411,6 +424,7 @@
|
|||
android:layout_height="40dp"
|
||||
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"
|
||||
|
@ -427,6 +441,7 @@
|
|||
android:layout_height="40dp"
|
||||
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"
|
||||
|
@ -443,6 +458,7 @@
|
|||
android:layout_width="32dp"
|
||||
android:layout_height="32dp"
|
||||
android:contentDescription="@string/action_more"
|
||||
android:importantForAccessibility="no"
|
||||
android:padding="4dp"
|
||||
app:layout_constraintBottom_toBottomOf="@id/status_reply"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
|
|
23
app/src/main/res/values/actions.xml
Normal file
23
app/src/main/res/values/actions.xml
Normal file
|
@ -0,0 +1,23 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<item name="action_expand_collapse_cw" type="id" />
|
||||
<item name="action_reply" type="id" />
|
||||
<item name="action_favourite" type="id" />
|
||||
<item name="action_unfavourite" type="id" />
|
||||
<item name="action_reblog" type="id" />
|
||||
<item name="action_unreblog" type="id" />
|
||||
<item name="action_open_profile" type="id" />
|
||||
<item name="action_open_media_1" type="id" />
|
||||
<item name="action_open_media_2" type="id" />
|
||||
<item name="action_open_media_3" type="id" />
|
||||
<item name="action_open_media_4" type="id" />
|
||||
<item name="action_open_mention" type="id" />
|
||||
<item name="action_expand_cw" type="id" />
|
||||
<item name="action_collapse_cw" type="id" />
|
||||
<item name="action_links" type="id" />
|
||||
<item name="action_mentions" type="id" />
|
||||
<item name="action_hashtags" type="id" />
|
||||
<item name="action_open_reblogger" type="id" />
|
||||
<item name="action_open_reblogged_by" type="id" />
|
||||
<item name="action_open_faved_by" type="id" />
|
||||
</resources>
|
|
@ -67,7 +67,9 @@
|
|||
<string name="action_quick_reply">Quick Reply</string>
|
||||
<string name="action_reply">Reply</string>
|
||||
<string name="action_reblog">Boost</string>
|
||||
<string name="action_unreblog">Remove boost</string>
|
||||
<string name="action_favourite">Favourite</string>
|
||||
<string name="action_unfavourite">Remove favourite</string>
|
||||
<string name="action_more">More</string>
|
||||
<string name="action_compose">Compose</string>
|
||||
<string name="action_login">Login with Mastodon</string>
|
||||
|
@ -114,6 +116,17 @@
|
|||
<string name="action_content_warning">Content warning</string>
|
||||
<string name="action_emoji_keyboard">Emoji keyboard</string>
|
||||
<string name="action_add_tab">Add Tab</string>
|
||||
<string name="action_links">Links</string>
|
||||
<string name="action_mentions">Mentions</string>
|
||||
<string name="action_hashtags">Hashtags</string>
|
||||
<string name="action_open_reblogger">Open boost author</string>
|
||||
<string name="action_open_reblogged_by">Show boosts</string>
|
||||
<string name="action_open_faved_by">Show favourites</string>
|
||||
|
||||
<string name="title_hashtags_dialog">Hashtags</string>
|
||||
<string name="title_mentions_dialog">Mentions</string>
|
||||
<string name="title_links_dialog">Links</string>
|
||||
<string name="action_open_media_n">Open media #%d</string>
|
||||
|
||||
<string name="download_image">Downloading %1$s</string>
|
||||
|
||||
|
@ -387,4 +400,36 @@
|
|||
<string name="max_tab_number_reached">maximum of %1$d tabs reached</string>
|
||||
|
||||
|
||||
<string name="description_status_media">
|
||||
Media: %s
|
||||
</string>
|
||||
<string name="description_status_cw">
|
||||
Content warning: %s
|
||||
</string>
|
||||
<string name="description_status_media_no_description_placeholder">
|
||||
No description
|
||||
</string>
|
||||
<string name="description_status_reblogged">
|
||||
Reblogged
|
||||
</string>
|
||||
<string name="description_status_favourited">
|
||||
Favourited
|
||||
</string>
|
||||
<string name="description_visiblity_public">
|
||||
Public
|
||||
</string>
|
||||
<string name="description_visiblity_unlisted">
|
||||
Unlisted
|
||||
</string>
|
||||
<string name="description_visiblity_private">
|
||||
Followers
|
||||
</string>
|
||||
<string name="description_visiblity_direct">
|
||||
Direct
|
||||
</string>
|
||||
<string name="description_status">
|
||||
<!-- Display name, cw?, content?, relative date, reposted by?, reposted?, favorited?, username, media?; visibility, fav number?, reblog number?-->
|
||||
%s; %s; %s, %s, %s; %s, %s, %s, %s; %s, %s, %s
|
||||
</string>
|
||||
|
||||
</resources>
|
||||
|
|
Loading…
Reference in a new issue