chore(merging-upstream): bunch of conflicts to solve

This commit is contained in:
LucasGGamerM
2024-02-14 20:50:54 -03:00
parent 8dffbff97c
commit 0af8dbf09b
2000 changed files with 52238 additions and 10716 deletions

View File

@@ -1,16 +1,19 @@
package org.joinmastodon.android.ui.sheets;
package org.joinmastodon.android.ui;
import android.annotation.SuppressLint;
import android.app.Activity;
import android.app.ProgressDialog;
import android.content.Intent;
import android.graphics.drawable.Animatable;
import android.graphics.drawable.ColorDrawable;
import android.graphics.drawable.Drawable;
import android.os.Build;
import android.text.SpannableStringBuilder;
import android.view.View;
import android.view.ViewGroup;
import android.view.WindowInsets;
import android.widget.FrameLayout;
import android.widget.ImageButton;
import android.widget.ImageView;
import android.widget.RadioButton;
import android.widget.TextView;
@@ -23,14 +26,15 @@ import org.joinmastodon.android.api.session.AccountSession;
import org.joinmastodon.android.api.session.AccountSessionManager;
import org.joinmastodon.android.fragments.HomeFragment;
import org.joinmastodon.android.fragments.SplashFragment;
import org.joinmastodon.android.ui.ClickableSingleViewRecyclerAdapter;
import org.joinmastodon.android.ui.M3AlertDialogBuilder;
import org.joinmastodon.android.ui.OutlineProviders;
import org.joinmastodon.android.fragments.onboarding.CustomWelcomeFragment;
import org.joinmastodon.android.ui.text.HtmlParser;
import org.joinmastodon.android.ui.utils.CustomEmojiHelper;
import org.joinmastodon.android.ui.utils.UiUtils;
import org.joinmastodon.android.ui.views.CheckableRelativeLayout;
import java.util.ArrayList;
import java.util.List;
import java.util.function.BiConsumer;
import java.util.stream.Collectors;
import androidx.annotation.DrawableRes;
@@ -57,15 +61,24 @@ import me.grishka.appkit.views.UsableRecyclerView;
public class AccountSwitcherSheet extends BottomSheet{
private final Activity activity;
private final HomeFragment fragment;
private final boolean externalShare, openInApp;
private BiConsumer<String, Boolean> onClick;
private UsableRecyclerView list;
private List<WrappedAccount> accounts;
private ListImageLoaderWrapper imgLoader;
private AccountsAdapter accountsAdapter;
private Runnable onLoggedOutCallback;
public AccountSwitcherSheet(@NonNull Activity activity, @Nullable HomeFragment fragment){
this(activity, fragment, false, false);
}
public AccountSwitcherSheet(@NonNull Activity activity, @Nullable HomeFragment fragment, boolean externalShare, boolean openInApp){
super(activity);
this.activity=activity;
this.fragment=fragment;
this.externalShare = externalShare;
this.openInApp = openInApp;
accounts=AccountSessionManager.getInstance().getLoggedInAccounts().stream().map(WrappedAccount::new).collect(Collectors.toList());
@@ -78,13 +91,30 @@ public class AccountSwitcherSheet extends BottomSheet{
View handle=new View(activity);
handle.setBackgroundResource(R.drawable.bg_bottom_sheet_handle);
handle.setLayoutParams(new ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, V.dp(36)));
adapter.addAdapter(new SingleViewRecyclerAdapter(handle));
adapter.addAdapter(new AccountsAdapter());
adapter.addAdapter(new ClickableSingleViewRecyclerAdapter(makeSimpleListItem(R.string.add_account, R.drawable.ic_add_24px), ()->{
Nav.go(activity, SplashFragment.class, null);
dismiss();
}));
adapter.addAdapter(new ClickableSingleViewRecyclerAdapter(makeSimpleListItem(R.string.log_out_all_accounts, R.drawable.ic_logout_24px), this::confirmLogOutAll));
if (externalShare) {
FrameLayout shareHeading = new FrameLayout(activity);
activity.getLayoutInflater().inflate(R.layout.item_external_share_heading, shareHeading);
((TextView) shareHeading.findViewById(R.id.title)).setText(openInApp
? R.string.sk_external_share_or_open_title
: R.string.sk_external_share_title);
adapter.addAdapter(new SingleViewRecyclerAdapter(shareHeading));
setOnDismissListener((d) -> activity.finish());
}
adapter.addAdapter(accountsAdapter = new AccountsAdapter());
if (!externalShare) {
adapter.addAdapter(new ClickableSingleViewRecyclerAdapter(makeSimpleListItem(R.string.add_account, R.drawable.ic_fluent_add_24_regular), () -> {
Nav.go(activity, CustomWelcomeFragment.class, null);
dismiss();
}));
// disabled in megalodon
// adapter.addAdapter(new ClickableSingleViewRecyclerAdapter(makeSimpleListItem(R.string.log_out_all_accounts, R.drawable.ic_fluent_person_arrow_right_24_filled), this::confirmLogOutAll));
}
list.setAdapter(adapter);
@@ -101,9 +131,14 @@ public class AccountSwitcherSheet extends BottomSheet{
return this;
}
public void setOnClick(BiConsumer<String, Boolean> onClick) {
this.onClick = onClick;
}
private void confirmLogOut(String accountID){
AccountSession session=AccountSessionManager.getInstance().getAccount(accountID);
new M3AlertDialogBuilder(activity)
.setTitle(R.string.log_out)
.setMessage(activity.getString(R.string.confirm_log_out, session.getFullUsername()))
.setPositiveButton(R.string.log_out, (dialog, which) -> logOut(accountID))
.setNegativeButton(R.string.cancel, null)
@@ -167,6 +202,22 @@ public class AccountSwitcherSheet extends BottomSheet{
}
}
private void onLoggedOut(String accountID){
AccountSessionManager.getInstance().removeAccount(accountID);
String activeAccountID = fragment != null
? fragment.getAccountID()
: AccountSessionManager.getInstance().getLastActiveAccountID();
if (accountID.equals(activeAccountID)) {
activity.finish();
activity.startActivity(new Intent(activity, MainActivity.class));
} else {
accounts.stream().filter(w -> accountID.equals(w.session.getID())).findAny().ifPresent(w -> {
accountsAdapter.notifyItemRemoved(accounts.indexOf(w));
accounts.remove(w);
});
}
}
@Override
protected void onWindowInsetsUpdated(WindowInsets insets){
if(Build.VERSION.SDK_INT>=29){
@@ -226,25 +277,38 @@ public class AccountSwitcherSheet extends BottomSheet{
private final TextView name, username;
private final ImageView avatar;
private final CheckableRelativeLayout view;
private final View radioButton, extraBtnWrap;
private final ImageButton extraBtn;
public AccountViewHolder(){
super(activity, R.layout.item_account_switcher, list);
name=findViewById(R.id.name);
username=findViewById(R.id.username);
View radioButton=findViewById(R.id.radiobtn);
radioButton=findViewById(R.id.radiobtn);
radioButton.setBackground(new RadioButton(activity).getButtonDrawable());
avatar=findViewById(R.id.avatar);
avatar.setOutlineProvider(OutlineProviders.roundedRect(OutlineProviders.RADIUS_MEDIUM));
avatar.setClipToOutline(true);
view=(CheckableRelativeLayout) itemView;
extraBtnWrap = findViewById(R.id.extra_btn_wrap);
extraBtn = findViewById(R.id.extra_btn);
extraBtn.setOnClickListener(this::onExtraBtnClick);
}
@SuppressLint("SetTextI18n")
@Override
public void onBind(AccountSession item){
name.setText(item.self.displayName);
HtmlParser.setTextWithCustomEmoji(name, item.self.getDisplayName(), item.self.emojis);
username.setText(item.getFullUsername());
view.setChecked(AccountSessionManager.getInstance().getLastActiveAccountID().equals(item.getID()));
radioButton.setVisibility(externalShare ? View.GONE : View.VISIBLE);
extraBtnWrap.setVisibility(externalShare && openInApp ? View.VISIBLE : View.GONE);
if (externalShare) view.setCheckable(false);
else {
String accountId = fragment != null
? fragment.getAccountID()
: AccountSessionManager.getInstance().getLastActiveAccountID();
view.setChecked(accountId.equals(item.getID()));
}
}
@Override
@@ -259,23 +323,29 @@ public class AccountSwitcherSheet extends BottomSheet{
setImage(index, null);
}
private void onExtraBtnClick(View view) {
setOnDismissListener(null);
dismiss();
onClick.accept(item.getID(), true);
}
@Override
public void onClick(){
dismiss();
if(AccountSessionManager.getInstance().getLastActiveAccountID().equals(item.getID())){
if(fragment!=null){
fragment.setCurrentTab(R.id.tab_profile);
}
setOnDismissListener(null);
if (onClick != null) {
dismiss();
onClick.accept(item.getID(), false);
return;
}
if(AccountSessionManager.getInstance().tryGetAccount(item.getID())!=null){
AccountSessionManager.getInstance().setLastActiveAccountID(item.getID());
((MainActivity)activity).restartHomeFragment();
((MainActivity)activity).restartActivity();
}
}
@Override
public boolean onLongClick(){
if (externalShare) return false;
confirmLogOut(item.getID());
return true;
}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,87 @@
package org.joinmastodon.android.ui;
import android.app.Activity;
import android.graphics.Typeface;
import android.graphics.drawable.ColorDrawable;
import android.os.Build;
import android.view.View;
import android.view.ViewGroup;
import android.view.WindowInsets;
import android.widget.FrameLayout;
import android.widget.LinearLayout;
import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.recyclerview.widget.LinearLayoutManager;
import org.joinmastodon.android.R;
import org.joinmastodon.android.model.Attachment;
import org.joinmastodon.android.ui.utils.UiUtils;
import me.grishka.appkit.utils.SingleViewRecyclerAdapter;
import me.grishka.appkit.utils.V;
import me.grishka.appkit.views.BottomSheet;
import me.grishka.appkit.views.UsableRecyclerView;
public class ImageDescriptionSheet extends BottomSheet{
private UsableRecyclerView list;
public ImageDescriptionSheet(@NonNull Activity activity, Attachment attachment){
super(activity);
View handleView=new View(activity);
handleView.setBackgroundResource(R.drawable.bg_bottom_sheet_handle);
ViewGroup handle=new FrameLayout(activity);
handle.addView(handleView);
handle.setLayoutParams(new FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, V.dp(24)));
TextView textView = new TextView(activity);
if (attachment.description == null || attachment.description.isEmpty()) {
textView.setText(R.string.media_no_description);
textView.setTypeface(null, Typeface.ITALIC);
} else {
textView.setText(attachment.description);
textView.setTextIsSelectable(true);
}
TextView heading=new TextView(activity);
heading.setText(R.string.sk_image_description);
heading.setAllCaps(true);
heading.setTypeface(null, Typeface.BOLD);
heading.setPadding(0, V.dp(24), 0, V.dp(8));
LinearLayout linearLayout = new LinearLayout(activity);
linearLayout.setOrientation(LinearLayout.VERTICAL);
linearLayout.setPadding(V.dp(24), 0, V.dp(24), 0);
linearLayout.addView(heading);
linearLayout.addView(textView);
FrameLayout layout=new FrameLayout(activity);
layout.addView(handle);
layout.addView(linearLayout);
list=new UsableRecyclerView(activity);
list.setLayoutManager(new LinearLayoutManager(activity));
list.setBackgroundResource(R.drawable.bg_bottom_sheet);
list.setAdapter(new SingleViewRecyclerAdapter(layout));
list.setClipToPadding(false);
setContentView(list);
setNavigationBarBackground(new ColorDrawable(UiUtils.getThemeColor(activity, R.attr.colorM3Surface)), !UiUtils.isDarkTheme());
}
@Override
protected void onWindowInsetsUpdated(WindowInsets insets){
if(Build.VERSION.SDK_INT>=29){
int tappableBottom=insets.getTappableElementInsets().bottom;
int insetBottom=insets.getSystemWindowInsetBottom();
if(tappableBottom==0 && insetBottom>0){
list.setPadding(0, 0, 0, V.dp(48)-insetBottom);
}else{
list.setPadding(0, 0, 0, V.dp(24));
}
}else{
list.setPadding(0, 0, 0, V.dp(24));
}
}
}

View File

@@ -65,7 +65,7 @@ public class M3AlertDialogBuilder extends AlertDialog.Builder{
View title=alert.findViewById(titleID);
if(title!=null){
int pad=V.dp(24);
title.setPadding(pad, pad, pad, pad);
title.setPadding(pad, pad, pad, V.dp(18));
}
}
int titleDividerID=getContext().getResources().getIdentifier("titleDividerNoCustom", "id", "android");

View File

@@ -1,4 +1,4 @@
package org.joinmastodon.android.ui.sheets;
package org.joinmastodon.android.ui;
import android.annotation.SuppressLint;
import android.content.Context;
@@ -14,7 +14,6 @@ import android.widget.TextView;
import org.joinmastodon.android.R;
import org.joinmastodon.android.api.session.AccountSessionManager;
import org.joinmastodon.android.model.Account;
import org.joinmastodon.android.ui.OutlineProviders;
import org.joinmastodon.android.ui.text.HtmlParser;
import org.joinmastodon.android.ui.utils.UiUtils;

View File

@@ -1,4 +1,4 @@
package org.joinmastodon.android.ui.sheets;
package org.joinmastodon.android.ui;
import android.content.Context;

View File

@@ -37,6 +37,16 @@ public class OutlineProviders{
}
};
private final static int BUTTON_BG_HEIGHT=V.dp(40);
public static final ViewOutlineProvider M3_BUTTON=new ViewOutlineProvider(){
@Override
public void getOutline(View view, Outline outline){
int viewHeight=view.getHeight();
int top=Math.floorDiv(viewHeight - BUTTON_BG_HEIGHT, 2);
outline.setRoundRect(0, top, view.getWidth(), top + BUTTON_BG_HEIGHT, V.dp(20));
}
};
public static ViewOutlineProvider roundedRect(int dp){
ViewOutlineProvider provider=roundedRects.get(dp);
if(provider!=null)

View File

@@ -10,12 +10,16 @@ import java.util.List;
import androidx.annotation.NonNull;
import me.grishka.appkit.utils.V;
public class PhotoLayoutHelper{
public static final int MAX_WIDTH=1000;
public static final int MAX_HEIGHT=1777; // 9:16
public static final int MIN_HEIGHT=563;
public static final int MAX_HEIGHT=1700;
public static final float GAP=1.5f;
// 2 * margin + close button height - gap. i don't know if the gap subtraction is correct
public static final int MIN_HEIGHT = Math.round(V.dp(2 * 12) + V.dp(40) - GAP);
@NonNull
public static TiledLayoutResult processThumbs(List<Attachment> thumbs){
float maxRatio=MAX_WIDTH/(float)MAX_HEIGHT;

View File

@@ -1,4 +1,4 @@
package org.joinmastodon.android.ui.sheets;
package org.joinmastodon.android.ui;
import android.content.Context;
import android.graphics.drawable.ColorDrawable;

View File

@@ -45,13 +45,14 @@ public class SearchViewHelper{
searchEdit.setHint(hint);
searchEdit.setInputType(InputType.TYPE_TEXT_VARIATION_FILTER);
searchEdit.setBackground(null);
searchEdit.setPadding(0, 0, 0, 0);
searchEdit.addTextChangedListener(new SimpleTextWatcher(e->{
searchEdit.removeCallbacks(debouncer);
searchEdit.postDelayed(debouncer, 500);
boolean newIsEmpty=e.length()==0;
if(isEmpty!=newIsEmpty){
isEmpty=newIsEmpty;
V.setVisibilityAnimated(clearSearchButton, isEmpty ? View.INVISIBLE : View.VISIBLE);
V.setVisibilityAnimated(clearSearchButton, isEmpty ? View.GONE : View.VISIBLE);
}
if(listenerWithoutDebounce!=null)
listenerWithoutDebounce.accept(e.toString());
@@ -70,7 +71,7 @@ public class SearchViewHelper{
searchLayout.addView(searchEdit, new LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.MATCH_PARENT, 1f));
clearSearchButton=new ImageButton(context);
clearSearchButton.setImageResource(R.drawable.ic_baseline_close_24);
clearSearchButton.setImageResource(R.drawable.ic_fluent_dismiss_24_regular);
clearSearchButton.setContentDescription(context.getString(R.string.clear));
clearSearchButton.setImageTintList(ColorStateList.valueOf(UiUtils.getThemeColor(context, R.attr.colorM3OnSurfaceVariant)));
clearSearchButton.setBackground(UiUtils.getThemeDrawable(toolbarContext, android.R.attr.actionBarItemBackground));
@@ -79,7 +80,7 @@ public class SearchViewHelper{
searchEdit.removeCallbacks(debouncer);
debouncer.run();
});
clearSearchButton.setVisibility(View.INVISIBLE);
clearSearchButton.setVisibility(View.GONE);
searchLayout.addView(clearSearchButton, new LinearLayout.LayoutParams(V.dp(56), ViewGroup.LayoutParams.MATCH_PARENT));
}

View File

@@ -90,7 +90,7 @@ public class Snackbar{
if(current!=null)
current.dismiss();
current=this;
WindowManager.LayoutParams lp=new WindowManager.LayoutParams(WindowManager.LayoutParams.LAST_APPLICATION_WINDOW, WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE | WindowManager.LayoutParams.FLAG_LAYOUT_IN_SCREEN, PixelFormat.TRANSLUCENT);
WindowManager.LayoutParams lp=new WindowManager.LayoutParams(WindowManager.LayoutParams.TYPE_APPLICATION_PANEL, WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE | WindowManager.LayoutParams.FLAG_LAYOUT_IN_SCREEN, PixelFormat.TRANSLUCENT);
lp.width=ViewGroup.LayoutParams.MATCH_PARENT;
lp.height=ViewGroup.LayoutParams.WRAP_CONTENT;
lp.gravity=Gravity.BOTTOM;

View File

@@ -0,0 +1,229 @@
package org.joinmastodon.android.ui.displayitems;
import android.app.Activity;
import android.content.Context;
import android.graphics.drawable.Animatable;
import android.graphics.drawable.Drawable;
import android.text.SpannableStringBuilder;
import android.text.TextUtils;
import android.view.View;
import android.view.ViewGroup;
import android.widget.ImageView;
import android.widget.ProgressBar;
import android.widget.TextView;
import androidx.recyclerview.widget.RecyclerView;
import org.joinmastodon.android.R;
import org.joinmastodon.android.api.session.AccountSessionManager;
import org.joinmastodon.android.fragments.BaseStatusListFragment;
import org.joinmastodon.android.model.Account;
import org.joinmastodon.android.model.Notification;
import org.joinmastodon.android.model.Relationship;
import org.joinmastodon.android.ui.OutlineProviders;
import org.joinmastodon.android.ui.text.HtmlParser;
import org.joinmastodon.android.ui.utils.CustomEmojiHelper;
import org.joinmastodon.android.ui.utils.UiUtils;
import org.joinmastodon.android.ui.views.ProgressBarButton;
import java.util.Collections;
import me.grishka.appkit.imageloader.ImageLoaderViewHolder;
import me.grishka.appkit.imageloader.requests.ImageLoaderRequest;
import me.grishka.appkit.imageloader.requests.UrlImageLoaderRequest;
import me.grishka.appkit.utils.V;
public class AccountCardStatusDisplayItem extends StatusDisplayItem{
private final Account account;
private final Notification notification;
public ImageLoaderRequest avaRequest, coverRequest;
public CustomEmojiHelper emojiHelper=new CustomEmojiHelper();
public CharSequence parsedName, parsedBio;
public AccountCardStatusDisplayItem(String parentID, BaseStatusListFragment parentFragment, String accountID, Account account, Notification notification){
super(parentID, parentFragment);
this.account=account;
this.notification=notification;
avaRequest=new UrlImageLoaderRequest(
TextUtils.isEmpty(account.avatar) ? AccountSessionManager.get(accountID).getDefaultAvatarUrl() : account.avatar,
V.dp(50), V.dp(50));
if(!TextUtils.isEmpty(account.header))
coverRequest=new UrlImageLoaderRequest(account.header, 1000, 1000);
parsedBio=HtmlParser.parse(account.note, account.emojis, Collections.emptyList(), Collections.emptyList(), parentFragment.getAccountID());
if(account.emojis.isEmpty()){
parsedName=account.getDisplayName();
}else{
parsedName=HtmlParser.parseCustomEmoji(account.getDisplayName(), account.emojis);
emojiHelper.setText(new SpannableStringBuilder(parsedName).append(parsedBio));
}
}
@Override
public Type getType(){
return Type.ACCOUNT_CARD;
}
@Override
public int getImageCount(){
return 2+emojiHelper.getImageCount();
}
@Override
public ImageLoaderRequest getImageRequest(int index){
return switch(index){
case 0 -> avaRequest;
case 1 -> coverRequest;
default -> emojiHelper.getImageRequest(index-2);
};
}
public static class Holder extends StatusDisplayItem.Holder<AccountCardStatusDisplayItem> implements ImageLoaderViewHolder{
private final ImageView cover, avatar;
private final TextView name, username, bio, followersCount, followingCount, postsCount, followersLabel, followingLabel, postsLabel;
private final ProgressBarButton actionButton, acceptButton, rejectButton;
private final ProgressBar actionProgress, acceptProgress, rejectProgress;
private final View actionWrap, acceptWrap, rejectWrap;
private Relationship relationship;
public Holder(Context context, ViewGroup parent){
super(context, R.layout.display_item_account_card, parent);
cover=findViewById(R.id.cover);
avatar=findViewById(R.id.avatar);
name=findViewById(R.id.name);
username=findViewById(R.id.username);
bio=findViewById(R.id.bio);
followersCount=findViewById(R.id.followers_count);
followersLabel=findViewById(R.id.followers_label);
followingCount=findViewById(R.id.following_count);
followingLabel=findViewById(R.id.following_label);
postsCount=findViewById(R.id.posts_count);
postsLabel=findViewById(R.id.posts_label);
actionButton=findViewById(R.id.action_btn);
actionProgress=findViewById(R.id.action_progress);
actionWrap=findViewById(R.id.action_btn_wrap);
acceptButton=findViewById(R.id.accept_btn);
acceptProgress=findViewById(R.id.accept_progress);
acceptWrap=findViewById(R.id.accept_btn_wrap);
rejectButton=findViewById(R.id.reject_btn);
rejectProgress=findViewById(R.id.reject_progress);
rejectWrap=findViewById(R.id.reject_btn_wrap);
View card=findViewById(R.id.card);
card.setOutlineProvider(OutlineProviders.roundedRect(12));
card.setClipToOutline(true);
avatar.setOutlineProvider(OutlineProviders.roundedRect(15));
avatar.setClipToOutline(true);
View border=findViewById(R.id.avatar_border);
border.setOutlineProvider(OutlineProviders.roundedRect(17));
border.setClipToOutline(true);
cover.setOutlineProvider(OutlineProviders.roundedRect(9));
cover.setClipToOutline(true);
actionButton.setOnClickListener(this::onActionButtonClick);
acceptButton.setOnClickListener(this::onFollowRequestButtonClick);
rejectButton.setOnClickListener(this::onFollowRequestButtonClick);
card.setOnClickListener(v->onClick());
}
@Override
public boolean isEnabled(){
return false;
}
@Override
public void onBind(AccountCardStatusDisplayItem item){
name.setText(item.parsedName);
username.setText('@'+item.account.acct);
bio.setText(item.parsedBio);
followersCount.setText(UiUtils.abbreviateNumber(item.account.followersCount));
followingCount.setText(UiUtils.abbreviateNumber(item.account.followingCount));
postsCount.setText(UiUtils.abbreviateNumber(item.account.statusesCount));
followersLabel.setText(item.parentFragment.getResources().getQuantityString(R.plurals.followers, (int)Math.min(999, item.account.followersCount)));
followingLabel.setText(item.parentFragment.getResources().getQuantityString(R.plurals.following, (int)Math.min(999, item.account.followingCount)));
postsLabel.setText(item.parentFragment.getResources().getQuantityString(R.plurals.sk_posts_count_label, (int)(item.account.statusesCount%1000), item.account.statusesCount));
followersCount.setVisibility(item.account.followersCount < 0 ? View.GONE : View.VISIBLE);
followersLabel.setVisibility(item.account.followersCount < 0 ? View.GONE : View.VISIBLE);
followingCount.setVisibility(item.account.followingCount < 0 ? View.GONE : View.VISIBLE);
followingLabel.setVisibility(item.account.followingCount < 0 ? View.GONE : View.VISIBLE);
relationship=item.parentFragment.getRelationship(item.account.id);
UiUtils.setExtraTextInfo(item.parentFragment.getContext(), null,true, false, false, item.account);
if(item.notification.type==Notification.Type.FOLLOW_REQUEST && (relationship==null || !relationship.followedBy)){
actionWrap.setVisibility(View.GONE);
acceptWrap.setVisibility(View.VISIBLE);
rejectWrap.setVisibility(View.VISIBLE);
acceptButton.setCompoundDrawableTintList(acceptButton.getTextColors());
acceptProgress.setIndeterminateTintList(acceptButton.getTextColors());
rejectButton.setCompoundDrawableTintList(rejectButton.getTextColors());
rejectProgress.setIndeterminateTintList(rejectButton.getTextColors());
}else if(relationship==null){
actionWrap.setVisibility(View.GONE);
acceptWrap.setVisibility(View.GONE);
rejectWrap.setVisibility(View.GONE);
}else{
actionWrap.setVisibility(View.VISIBLE);
acceptWrap.setVisibility(View.GONE);
rejectWrap.setVisibility(View.GONE);
UiUtils.setRelationshipToActionButtonM3(relationship, actionButton);
}
}
private void onFollowRequestButtonClick(View v) {
itemView.setHasTransientState(true);
UiUtils.handleFollowRequest((Activity) v.getContext(), item.account, item.parentFragment.getAccountID(), null, v == acceptButton, relationship, rel -> {
if(v.getContext()==null || rel==null) return;
itemView.setHasTransientState(false);
item.parentFragment.putRelationship(item.account.id, rel);
RecyclerView.Adapter<? extends RecyclerView.ViewHolder> adapter = getBindingAdapter();
if (!rel.requested && !rel.followedBy && adapter != null) {
int index = item.parentFragment.getDisplayItems().indexOf(item);
item.parentFragment.getDisplayItems().remove(index);
item.parentFragment.getDisplayItems().remove(index - 1);
adapter.notifyItemRangeRemoved(getLayoutPosition()-1, 2);
} else {
rebind();
}
});
}
private void onActionButtonClick(View v){
itemView.setHasTransientState(true);
UiUtils.performAccountAction((Activity) v.getContext(), item.account, item.parentFragment.getAccountID(), relationship, actionButton, this::setActionProgressVisible, rel->{
if(v.getContext()==null) return;
itemView.setHasTransientState(false);
item.parentFragment.putRelationship(item.account.id, rel);
rebind();
});
}
private void setActionProgressVisible(boolean visible){
actionButton.setTextVisible(!visible);
actionProgress.setVisibility(visible ? View.VISIBLE : View.GONE);
if(visible)
actionProgress.setIndeterminateTintList(actionButton.getTextColors());
actionButton.setClickable(!visible);
}
@Override
public void setImage(int index, Drawable image){
if(index==0){
avatar.setImageDrawable(image);
}else if(index==1){
cover.setImageDrawable(image);
}else{
item.emojiHelper.setImageDrawable(index-2, image);
name.invalidate();
bio.invalidate();
}
if(image instanceof Animatable && !((Animatable) image).isRunning())
((Animatable) image).start();
}
@Override
public void clearImage(int index){
setImage(index, null);
}
}
}

View File

@@ -32,7 +32,6 @@ import me.grishka.appkit.imageloader.requests.UrlImageLoaderRequest;
import me.grishka.appkit.utils.V;
public class AudioStatusDisplayItem extends StatusDisplayItem{
public final Status status;
public final Attachment attachment;
private final ImageLoaderRequest imageRequest;
@@ -40,7 +39,7 @@ public class AudioStatusDisplayItem extends StatusDisplayItem{
super(parentID, parentFragment);
this.status=status;
this.attachment=attachment;
imageRequest=new UrlImageLoaderRequest(TextUtils.isEmpty(attachment.previewUrl) ? status.account.avatarStatic : attachment.previewUrl, V.dp(100), V.dp(100));
imageRequest=new UrlImageLoaderRequest(TextUtils.isEmpty(attachment.previewUrl) ? (status.account != null ? status.account.avatarStatic : "") : attachment.previewUrl, V.dp(100), V.dp(100));
}
@Override
@@ -214,7 +213,7 @@ public class AudioStatusDisplayItem extends StatusDisplayItem{
}
private void setPlayButtonPlaying(boolean playing, boolean animated){
playPauseBtn.setImageResource(playing ? R.drawable.ic_pause_48px : R.drawable.ic_play_arrow_48px);
playPauseBtn.setImageResource(playing ? R.drawable.ic_fluent_pause_48_regular : R.drawable.ic_fluent_play_48_regular);
playPauseBtn.setContentDescription(item.parentFragment.getString(playing ? R.string.pause : R.string.play));
if(playing)
bgDrawable.startAnimation();

View File

@@ -3,10 +3,12 @@ package org.joinmastodon.android.ui.displayitems;
import android.app.Activity;
import android.view.View;
import android.view.ViewGroup;
import android.view.accessibility.AccessibilityNodeInfo;
import android.widget.CheckBox;
import org.joinmastodon.android.R;
import org.joinmastodon.android.fragments.BaseStatusListFragment;
import org.joinmastodon.android.fragments.report.ReportAddPostsChoiceFragment;
import org.joinmastodon.android.model.Account;
import org.joinmastodon.android.model.Status;
import org.joinmastodon.android.ui.views.CheckableRelativeLayout;
@@ -15,8 +17,8 @@ import java.time.Instant;
import java.util.function.Predicate;
public class CheckableHeaderStatusDisplayItem extends HeaderStatusDisplayItem{
public CheckableHeaderStatusDisplayItem(String parentID, Account user, Instant createdAt, BaseStatusListFragment parentFragment, String accountID, Status status, String extraText){
super(parentID, user, createdAt, parentFragment, accountID, status, extraText);
public CheckableHeaderStatusDisplayItem(String parentID, Account user, Instant createdAt, BaseStatusListFragment<?> parentFragment, String accountID, Status status, CharSequence extraText){
super(parentID, user, createdAt, parentFragment, accountID, status, extraText, null, null);
}
@Override
@@ -32,8 +34,16 @@ public class CheckableHeaderStatusDisplayItem extends HeaderStatusDisplayItem{
public Holder(Activity activity, ViewGroup parent){
super(activity, R.layout.display_item_header_checkable, parent);
checkbox=findViewById(R.id.checkbox);
view=(CheckableRelativeLayout) itemView;
view=findViewById(R.id.checkbox_wrap);
checkbox.setBackground(new CheckBox(activity).getButtonDrawable());
view.setOnClickListener(this::onToggle);
view.setAccessibilityDelegate(new View.AccessibilityDelegate(){
@Override
public void onInitializeAccessibilityNodeInfo(View host, AccessibilityNodeInfo info){
super.onInitializeAccessibilityNodeInfo(host, info);
info.setClassName(CheckBox.class.getName());
}
});
}
@Override
@@ -44,6 +54,12 @@ public class CheckableHeaderStatusDisplayItem extends HeaderStatusDisplayItem{
}
}
private void onToggle(View v){
if(item.parentFragment instanceof ReportAddPostsChoiceFragment reportFragment){
reportFragment.onToggleItem(item.parentID);
}
}
public void setIsChecked(Predicate<Holder> isChecked){
this.isChecked=isChecked;
}

View File

@@ -0,0 +1,42 @@
package org.joinmastodon.android.ui.displayitems;
import android.content.Context;
import android.view.ViewGroup;
import android.widget.Space;
import androidx.recyclerview.widget.RecyclerView;
import org.joinmastodon.android.fragments.BaseStatusListFragment;
import me.grishka.appkit.utils.V;
public class DummyStatusDisplayItem extends StatusDisplayItem {
public DummyStatusDisplayItem(String parentID, BaseStatusListFragment<?> parentFragment) {
super(parentID, parentFragment);
}
@Override
public Type getType() {
return Type.DUMMY;
}
public static class Holder extends StatusDisplayItem.Holder<DummyStatusDisplayItem> {
private final RecyclerView.LayoutParams params;
public Holder(Context context) {
super(new Space(context));
params=new RecyclerView.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 0);
// BetterItemAnimator appears not to handle InsetStatusItemDecoration's getItemOffsets
// correctly, causing removed inset views to jump while animating. i don't quite
// understand it, but this workaround appears to work.
// see InsetStatusItemDecoration#getItemOffsets
params.setMargins(0, 0, 0, V.dp(16));
itemView.setLayoutParams(params);
}
@Override
public void onBind(DummyStatusDisplayItem item) {}
}
}

View File

@@ -0,0 +1,418 @@
package org.joinmastodon.android.ui.displayitems;
import android.app.Activity;
import android.content.Context;
import android.graphics.Paint;
import android.graphics.drawable.Animatable;
import android.graphics.drawable.Drawable;
import android.os.Build;
import android.os.Bundle;
import android.util.DisplayMetrics;
import android.util.Pair;
import android.view.View;
import android.view.ViewGroup;
import android.widget.ImageButton;
import android.widget.LinearLayout;
import android.widget.ProgressBar;
import androidx.annotation.NonNull;
import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.LinearSmoothScroller;
import androidx.recyclerview.widget.RecyclerView;
import org.joinmastodon.android.E;
import org.joinmastodon.android.GlobalUserPreferences;
import org.joinmastodon.android.R;
import org.joinmastodon.android.api.MastodonAPIRequest;
import org.joinmastodon.android.api.requests.announcements.AddAnnouncementReaction;
import org.joinmastodon.android.api.requests.announcements.DeleteAnnouncementReaction;
import org.joinmastodon.android.api.requests.statuses.AddStatusReaction;
import org.joinmastodon.android.api.requests.statuses.DeleteStatusReaction;
import org.joinmastodon.android.api.requests.statuses.PleromaAddStatusReaction;
import org.joinmastodon.android.api.requests.statuses.PleromaDeleteStatusReaction;
import org.joinmastodon.android.api.session.AccountSession;
import org.joinmastodon.android.api.session.AccountSessionManager;
import org.joinmastodon.android.events.EmojiReactionsUpdatedEvent;
import org.joinmastodon.android.fragments.BaseStatusListFragment;
import org.joinmastodon.android.fragments.account_list.StatusEmojiReactionsListFragment;
import org.joinmastodon.android.model.Account;
import org.joinmastodon.android.model.Emoji;
import org.joinmastodon.android.model.EmojiReaction;
import org.joinmastodon.android.model.Status;
import org.joinmastodon.android.ui.CustomEmojiPopupKeyboard;
import org.joinmastodon.android.ui.utils.TextDrawable;
import org.joinmastodon.android.ui.utils.UiUtils;
import org.joinmastodon.android.ui.views.ProgressBarButton;
import me.grishka.appkit.Nav;
import me.grishka.appkit.api.Callback;
import me.grishka.appkit.api.ErrorResponse;
import me.grishka.appkit.imageloader.ImageLoaderRecyclerAdapter;
import me.grishka.appkit.imageloader.ImageLoaderViewHolder;
import me.grishka.appkit.imageloader.ListImageLoaderWrapper;
import me.grishka.appkit.imageloader.RecyclerViewDelegate;
import me.grishka.appkit.imageloader.requests.ImageLoaderRequest;
import me.grishka.appkit.imageloader.requests.UrlImageLoaderRequest;
import me.grishka.appkit.utils.BindableViewHolder;
import me.grishka.appkit.utils.V;
import me.grishka.appkit.views.UsableRecyclerView;
public class EmojiReactionsStatusDisplayItem extends StatusDisplayItem {
private final Drawable placeholder;
private final boolean hideEmpty, forAnnouncement, playGifs;
private final String accountID;
private static final float ALPHA_DISABLED=0.55f;
public EmojiReactionsStatusDisplayItem(String parentID, BaseStatusListFragment<?> parentFragment, Status status, String accountID, boolean hideEmpty, boolean forAnnouncement) {
super(parentID, parentFragment);
this.status=status;
this.hideEmpty=hideEmpty;
this.forAnnouncement=forAnnouncement;
this.accountID=accountID;
placeholder=parentFragment.getContext().getDrawable(R.drawable.image_placeholder).mutate();
placeholder.setBounds(0, 0, V.sp(24), V.sp(24));
playGifs=GlobalUserPreferences.playGifs;
}
@Override
public int getImageCount(){
return (int) status.reactions.stream().filter(r->r.getUrl(playGifs)!=null).count();
}
@Override
public ImageLoaderRequest getImageRequest(int index){
return status.reactions.get(index).request;
}
@Override
public Type getType(){
return Type.EMOJI_REACTIONS;
}
public boolean isHidden(){
return status.reactions.isEmpty() && hideEmpty;
}
// borrowed from ProfileFragment
private void setActionProgressVisible(Holder.EmojiReactionViewHolder vh, boolean visible){
if(vh==null) return;
vh.progress.setVisibility(visible ? View.VISIBLE : View.GONE);
vh.btn.setClickable(!visible);
vh.btn.setAlpha(visible ? ALPHA_DISABLED : 1);
}
private MastodonAPIRequest<?> createRequest(String name, int count, boolean delete, Holder.EmojiReactionViewHolder vh, Runnable cb, Runnable err){
setActionProgressVisible(vh, true);
boolean ak=parentFragment.isInstanceAkkoma();
boolean keepSpinning=delete && count == 1;
if(forAnnouncement){
MastodonAPIRequest<Object> req=delete
? new DeleteAnnouncementReaction(status.id, name)
: new AddAnnouncementReaction(status.id, name);
return req.setCallback(new Callback<>(){
@Override
public void onSuccess(Object result){
if(!keepSpinning) setActionProgressVisible(vh, false);
cb.run();
}
@Override
public void onError(ErrorResponse error){
setActionProgressVisible(vh, false);
error.showToast(parentFragment.getContext());
if(err!=null) err.run();
}
});
}else{
MastodonAPIRequest<Status> req=delete
? (ak ? new PleromaDeleteStatusReaction(status.id, name) : new DeleteStatusReaction(status.id, name))
: (ak ? new PleromaAddStatusReaction(status.id, name) : new AddStatusReaction(status.id, name));
return req.setCallback(new Callback<>(){
@Override
public void onSuccess(Status result){
if(!keepSpinning) setActionProgressVisible(vh, false);
cb.run();
}
@Override
public void onError(ErrorResponse error){
setActionProgressVisible(vh, false);
error.showToast(parentFragment.getContext());
if(err!=null) err.run();
}
});
}
}
public static class Holder extends StatusDisplayItem.Holder<EmojiReactionsStatusDisplayItem> implements ImageLoaderViewHolder, CustomEmojiPopupKeyboard.Listener {
private final UsableRecyclerView list;
private final LinearLayout root, line;
private CustomEmojiPopupKeyboard emojiKeyboard;
private final View space;
private final ImageButton addButton;
private final ProgressBar progress;
private final EmojiReactionsAdapter adapter;
private final ListImageLoaderWrapper imgLoader;
public Holder(Activity activity, ViewGroup parent) {
super(activity, R.layout.display_item_emoji_reactions, parent);
root=(LinearLayout) itemView;
line=findViewById(R.id.line);
list=findViewById(R.id.list);
imgLoader=new ListImageLoaderWrapper(activity, list, new RecyclerViewDelegate(list), null);
list.setAdapter(adapter=new EmojiReactionsAdapter(this, imgLoader));
addButton=findViewById(R.id.add_btn);
progress=findViewById(R.id.progress);
addButton.setOnClickListener(this::onReactClick);
space=findViewById(R.id.space);
list.setLayoutManager(new LinearLayoutManager(activity, LinearLayoutManager.HORIZONTAL, false));
}
@Override
public void onBind(EmojiReactionsStatusDisplayItem item) {
if(emojiKeyboard != null) root.removeView(emojiKeyboard.getView());
addButton.setSelected(false);
AccountSession session=item.parentFragment.getSession();
item.status.reactions.forEach(r->r.request=r.getUrl(item.playGifs)!=null
? new UrlImageLoaderRequest(r.getUrl(item.playGifs), V.sp(24), V.sp(24))
: null);
emojiKeyboard=new CustomEmojiPopupKeyboard(
(Activity) item.parentFragment.getContext(),
item.accountID,
AccountSessionManager.getInstance().getCustomEmojis(session.domain),
session.domain, true);
emojiKeyboard.setListener(this);
space.setVisibility(View.GONE);
root.addView(emojiKeyboard.getView());
boolean hidden=item.isHidden();
root.setVisibility(hidden ? View.GONE : View.VISIBLE);
line.setVisibility(hidden ? View.GONE : View.VISIBLE);
line.setPadding(
list.getPaddingLeft(),
hidden ? 0 : V.dp(8),
list.getPaddingRight(),
item.forAnnouncement ? V.dp(8) : 0
);
imgLoader.updateImages();
adapter.notifyDataSetChanged();
}
private void hideEmojiKeyboard(){
space.setVisibility(View.GONE);
addButton.setSelected(false);
if(emojiKeyboard.isVisible()) emojiKeyboard.toggleKeyboardPopup(null);
}
@Override
public void onEmojiSelected(Emoji emoji) {
addEmojiReaction(emoji.shortcode, emoji);
hideEmojiKeyboard();
}
@Override
public void onEmojiSelected(String emoji){
addEmojiReaction(emoji, null);
hideEmojiKeyboard();
}
private void addEmojiReaction(String emoji, Emoji info) {
int countBefore=item.status.reactions.size();
for(int i=0; i<item.status.reactions.size(); i++){
EmojiReaction r=item.status.reactions.get(i);
if(r.name.equals(emoji) && r.me){
RecyclerView.SmoothScroller scroller=new LinearSmoothScroller(list.getContext());
scroller.setTargetPosition(i);
list.getLayoutManager().startSmoothScroll(scroller);
return; // nothing to do, already added
}
}
progress.setVisibility(View.VISIBLE);
addButton.setClickable(false);
addButton.setAlpha(ALPHA_DISABLED);
Runnable resetBtn=()->{
progress.setVisibility(View.GONE);
addButton.setClickable(true);
addButton.setAlpha(1f);
};
Account me=AccountSessionManager.get(item.accountID).self;
EmojiReaction existing=null;
for(int i=0; i<item.status.reactions.size(); i++){
EmojiReaction r=item.status.reactions.get(i);
if(r.name.equals(emoji)){
existing=r;
break;
}
}
EmojiReaction finalExisting=existing;
item.createRequest(emoji, existing==null ? 1 : existing.count, false, null, ()->{
resetBtn.run();
if(finalExisting==null){
int pos=item.status.reactions.size();
item.status.reactions.add(pos, info!=null ? EmojiReaction.of(info, me) : EmojiReaction.of(emoji, me));
adapter.notifyItemRangeInserted(pos, 1);
RecyclerView.SmoothScroller scroller=new LinearSmoothScroller(list.getContext());
scroller.setTargetPosition(pos);
list.getLayoutManager().startSmoothScroll(scroller);
}else{
finalExisting.add(me);
adapter.notifyItemChanged(item.status.reactions.indexOf(finalExisting));
}
E.post(new EmojiReactionsUpdatedEvent(item.status.id, item.status.reactions, countBefore==0, adapter.parentHolder));
}, resetBtn).exec(item.accountID);
}
@Override
public void onBackspace() {}
private void onReactClick(View v){
emojiKeyboard.toggleKeyboardPopup(null);
v.setSelected(emojiKeyboard.isVisible());
space.setVisibility(emojiKeyboard.isVisible() ? View.VISIBLE : View.GONE);
DisplayMetrics displayMetrics = new DisplayMetrics();
int[] locationOnScreen = new int[2];
((Activity) v.getContext()).getWindowManager().getDefaultDisplay().getMetrics(displayMetrics);
v.getLocationOnScreen(locationOnScreen);
double fromScreenTop = (double) locationOnScreen[1] / displayMetrics.heightPixels;
if (fromScreenTop > 0.75) {
item.parentFragment.scrollBy(0, (int) (displayMetrics.heightPixels * 0.3));
}
}
@Override
public void setImage(int index, Drawable image){
View child=list.getChildAt(index);
if(child==null) return;
((EmojiReactionViewHolder) list.getChildViewHolder(child)).setImage(index, image);
}
@Override
public void clearImage(int index){
if(item.status.reactions.get(index).getUrl(item.playGifs)==null) return;
setImage(index, item.placeholder);
}
private class EmojiReactionsAdapter extends UsableRecyclerView.Adapter<EmojiReactionViewHolder> implements ImageLoaderRecyclerAdapter{
ListImageLoaderWrapper imgLoader;
Holder parentHolder;
public EmojiReactionsAdapter(Holder parentHolder, ListImageLoaderWrapper imgLoader){
super(imgLoader);
this.parentHolder=parentHolder;
this.imgLoader=imgLoader;
}
@NonNull
@Override
public EmojiReactionViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType){
return new EmojiReactionViewHolder(parent.getContext(), list);
}
@Override
public void onBindViewHolder(EmojiReactionViewHolder holder, int position){
holder.bind(Pair.create(item, item.status.reactions.get(position)));
super.onBindViewHolder(holder, position);
}
@Override
public int getItemCount(){
return item.status.reactions.size();
}
@Override
public int getImageCountForItem(int position){
return item.status.reactions.get(position).getUrl(item.playGifs)==null ? 0 : 1;
}
@Override
public ImageLoaderRequest getImageRequest(int position, int image){
return item.status.reactions.get(position).request;
}
}
private static class EmojiReactionViewHolder extends BindableViewHolder<Pair<EmojiReactionsStatusDisplayItem, EmojiReaction>> implements ImageLoaderViewHolder{
private final ProgressBarButton btn;
private final ProgressBar progress;
public EmojiReactionViewHolder(Context context, RecyclerView list){
super(context, R.layout.item_emoji_reaction, list);
btn=findViewById(R.id.btn);
progress=findViewById(R.id.progress);
itemView.setClickable(true);
}
@Override
public void setImage(int index, Drawable drawable){
drawable.setBounds(0, 0, V.sp(24), V.sp(24));
btn.setCompoundDrawablesRelative(drawable, null, null, null);
if(drawable instanceof Animatable) ((Animatable) drawable).start();
}
@Override
public void clearImage(int index){
setImage(index, item.first.placeholder);
}
@Override
public void onBind(Pair<EmojiReactionsStatusDisplayItem, EmojiReaction> item){
item.first.setActionProgressVisible(this, false);
EmojiReactionsStatusDisplayItem parent=item.first;
EmojiReaction reaction=item.second;
btn.setText(UiUtils.abbreviateNumber(reaction.count));
btn.setContentDescription(reaction.name);
if(Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) btn.setTooltipText(reaction.name);
if(reaction.getUrl(parent.playGifs)==null){
Paint p=new Paint();
p.setTextSize(V.sp(18));
TextDrawable drawable=new TextDrawable(p, reaction.name);
btn.setCompoundDrawablesRelative(drawable, null, null, null);
}else{
btn.setCompoundDrawablesRelative(item.first.placeholder, null, null, null);
}
btn.setSelected(reaction.me);
btn.setOnClickListener(e->{
boolean deleting=reaction.me;
parent.createRequest(reaction.name, reaction.count, deleting, this, ()->{
EmojiReactionsAdapter adapter = (EmojiReactionsAdapter) getBindingAdapter();
for(int i=0; i<parent.status.reactions.size(); i++){
EmojiReaction r=parent.status.reactions.get(i);
if(!r.name.equals(reaction.name)) continue;
if(deleting && r.count==1) {
parent.status.reactions.remove(i);
adapter.notifyItemRemoved(i);
break;
}
r.me=!deleting;
if(deleting) r.count--;
else r.count++;
adapter.notifyItemChanged(i);
break;
}
if(parent.isHidden()){
adapter.parentHolder.root.setVisibility(View.GONE);
adapter.parentHolder.line.setVisibility(View.GONE);
}
E.post(new EmojiReactionsUpdatedEvent(parent.status.id, parent.status.reactions, parent.status.reactions.isEmpty(), adapter.parentHolder));
adapter.parentHolder.imgLoader.updateImages();
}, null).exec(parent.parentFragment.getAccountID());
});
if (parent.parentFragment.isInstanceAkkoma()) {
// glitch-soc doesn't have this, afaik
btn.setOnLongClickListener(e->{
EmojiReaction emojiReaction=parent.status.reactions.get(getAbsoluteAdapterPosition());
Bundle args=new Bundle();
args.putString("account", parent.parentFragment.getAccountID());
args.putString("statusID", parent.status.id);
int atSymbolIndex = emojiReaction.name.indexOf("@");
args.putString("emoji", atSymbolIndex != -1 ? emojiReaction.name.substring(0, atSymbolIndex) : emojiReaction.name);
args.putString("url", emojiReaction.getUrl(parent.playGifs));
args.putInt("count", emojiReaction.count);
Nav.go(parent.parentFragment.getActivity(), StatusEmojiReactionsListFragment.class, args);
return true;
});
}
}
}
}
}

View File

@@ -12,10 +12,16 @@ import android.text.style.StyleSpan;
import android.text.style.TypefaceSpan;
import android.view.View;
import android.view.ViewGroup;
import android.widget.Button;
import android.widget.ImageView;
import android.widget.TextView;
import android.widget.Toast;
import org.joinmastodon.android.GlobalUserPreferences;
import org.joinmastodon.android.R;
import org.joinmastodon.android.api.session.AccountSession;
import org.joinmastodon.android.api.session.AccountSessionManager;
import org.joinmastodon.android.api.session.AccountSessionManager;
import org.joinmastodon.android.fragments.BaseStatusListFragment;
import org.joinmastodon.android.fragments.StatusEditHistoryFragment;
import org.joinmastodon.android.fragments.ThreadFragment;
@@ -24,6 +30,7 @@ import org.joinmastodon.android.fragments.account_list.StatusReblogsListFragment
import org.joinmastodon.android.fragments.account_list.StatusRelatedAccountListFragment;
import org.joinmastodon.android.model.Status;
import org.joinmastodon.android.ui.Snackbar;
import org.joinmastodon.android.model.StatusPrivacy;
import org.joinmastodon.android.ui.utils.UiUtils;
import org.parceler.Parcels;
@@ -40,15 +47,16 @@ import me.grishka.appkit.Nav;
import me.grishka.appkit.utils.V;
public class ExtendedFooterStatusDisplayItem extends StatusDisplayItem{
public final Status status;
public final String accountID;
private static final DateTimeFormatter TIME_FORMATTER=DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT);
private static final DateTimeFormatter TIME_FORMATTER_LONG=DateTimeFormatter.ofLocalizedTime(FormatStyle.MEDIUM);
private static final DateTimeFormatter DATE_FORMATTER=DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM);
public ExtendedFooterStatusDisplayItem(String parentID, BaseStatusListFragment parentFragment, Status status){
public ExtendedFooterStatusDisplayItem(String parentID, BaseStatusListFragment parentFragment, String accountID, Status status){
super(parentID, parentFragment);
this.status=status;
this.accountID=accountID;
}
@Override
@@ -58,13 +66,18 @@ public class ExtendedFooterStatusDisplayItem extends StatusDisplayItem{
public static class Holder extends StatusDisplayItem.Holder<ExtendedFooterStatusDisplayItem>{
private final TextView time, date, app, dateAppSeparator;
private final TextView favorites, reblogs, editHistory;
private final Button favorites, reblogs, editHistory, applicationName;
private final ImageView visibility;
private final Context context;
public Holder(Context context, ViewGroup parent){
super(context, R.layout.display_item_extended_footer, parent);
this.context = context;
reblogs=findViewById(R.id.reblogs);
favorites=findViewById(R.id.favorites);
editHistory=findViewById(R.id.edit_history);
applicationName=findViewById(R.id.application_name);
visibility=findViewById(R.id.visibility);
time=findViewById(R.id.time);
date=findViewById(R.id.date);
app=findViewById(R.id.app_name);
@@ -81,31 +94,39 @@ public class ExtendedFooterStatusDisplayItem extends StatusDisplayItem{
@Override
public void onBind(ExtendedFooterStatusDisplayItem item){
Status s=item.status;
favorites.setText(getFormattedPlural(R.plurals.x_favorites, item.status.favouritesCount));
reblogs.setText(getFormattedPlural(R.plurals.x_reblogs, item.status.reblogsCount));
favorites.setCompoundDrawablesRelativeWithIntrinsicBounds(GlobalUserPreferences.likeIcon ? R.drawable.ic_fluent_heart_20_regular : R.drawable.ic_fluent_star_20_regular, 0, 0, 0);
favorites.setText(context.getResources().getQuantityString(R.plurals.x_favorites, (int)(s.favouritesCount%1000), s.favouritesCount));
reblogs.setText(context.getResources().getQuantityString(R.plurals.x_reblogs, (int) (s.reblogsCount % 1000), s.reblogsCount));
reblogs.setVisibility(s.visibility != StatusPrivacy.DIRECT ? View.VISIBLE : View.GONE);
if(s.editedAt!=null){
editHistory.setVisibility(View.VISIBLE);
ZonedDateTime dt=s.editedAt.atZone(ZoneId.systemDefault());
String time=TIME_FORMATTER.format(dt);
if(!dt.toLocalDate().equals(LocalDate.now())){
time+=" · "+DATE_FORMATTER.format(dt);
}
editHistory.setText(getFormattedSubstitutedString(R.string.last_edit_at_x, time));
editHistory.setText(UiUtils.formatRelativeTimestampAsMinutesAgo(itemView.getContext(), s.editedAt, false));
}else{
editHistory.setVisibility(View.GONE);
}
ZonedDateTime dt=item.status.createdAt.atZone(ZoneId.systemDefault());
time.setText(TIME_FORMATTER.format(dt));
date.setText(DATE_FORMATTER.format(dt));
if(item.status.application!=null && !TextUtils.isEmpty(item.status.application.name)){
app.setVisibility(View.VISIBLE);
dateAppSeparator.setVisibility(View.VISIBLE);
app.setText(item.status.application.name);
app.setEnabled(!TextUtils.isEmpty(item.status.application.website));
}else{
app.setVisibility(View.GONE);
dateAppSeparator.setVisibility(View.GONE);
String timeStr=item.status.createdAt != null ? TIME_FORMATTER.format(item.status.createdAt.atZone(ZoneId.systemDefault())) : null;
if (item.status.application!=null && !TextUtils.isEmpty(item.status.application.name)) {
time.setText(timeStr != null ? item.parentFragment.getString(R.string.timestamp_via_app, timeStr, "") : "");
applicationName.setText(item.status.application.name);
if (item.status.application.website != null && item.status.application.website.toLowerCase().startsWith("https://")) {
applicationName.setOnClickListener(e -> UiUtils.openURL(context, null, item.status.application.website));
} else {
applicationName.setEnabled(false);
}
} else {
time.setText(timeStr);
applicationName.setVisibility(View.GONE);
}
visibility.setImageResource(switch (s.visibility) {
case PUBLIC -> R.drawable.ic_fluent_earth_20_regular;
case UNLISTED -> R.drawable.ic_fluent_lock_open_20_regular;
case PRIVATE -> R.drawable.ic_fluent_lock_closed_20_filled;
case DIRECT -> R.drawable.ic_fluent_mention_20_regular;
case LOCAL -> R.drawable.ic_fluent_eye_20_regular;
});
}
@Override
@@ -151,6 +172,7 @@ public class ExtendedFooterStatusDisplayItem extends StatusDisplayItem{
}
private void startAccountListFragment(Class<? extends StatusRelatedAccountListFragment> cls){
if(item.status.preview) return;
Bundle args=new Bundle();
args.putString("account", item.parentFragment.getAccountID());
args.putParcelable("status", Parcels.wrap(item.status));
@@ -158,9 +180,11 @@ public class ExtendedFooterStatusDisplayItem extends StatusDisplayItem{
}
private void startEditHistoryFragment(){
if(item.status.preview) return;
Bundle args=new Bundle();
args.putString("account", item.parentFragment.getAccountID());
args.putString("id", item.status.id);
args.putString("url", item.status.url);
Nav.go(item.parentFragment.getActivity(), StatusEditHistoryFragment.class, args);
}

View File

@@ -0,0 +1,57 @@
package org.joinmastodon.android.ui.displayitems;
import android.content.Context;
import android.net.Uri;
import android.text.TextUtils;
import android.view.View;
import android.view.ViewGroup;
import android.widget.TextView;
import org.joinmastodon.android.R;
import org.joinmastodon.android.fragments.BaseStatusListFragment;
import org.joinmastodon.android.model.Attachment;
import org.joinmastodon.android.ui.utils.UiUtils;
public class FileStatusDisplayItem extends StatusDisplayItem{
private final Attachment attachment;
public FileStatusDisplayItem(String parentID, BaseStatusListFragment<?> parentFragment, Attachment attachment) {
super(parentID, parentFragment);
this.attachment=attachment;
}
@Override
public Type getType() {
return Type.FILE;
}
public static class Holder extends StatusDisplayItem.Holder<FileStatusDisplayItem> {
private final TextView title, domain;
public Holder(Context context, ViewGroup parent) {
super(context, R.layout.display_item_file, parent);
title=findViewById(R.id.title);
domain=findViewById(R.id.domain);
findViewById(R.id.inner).setOnClickListener(this::onClick);
}
@Override
public void onBind(FileStatusDisplayItem item) {
Uri url = Uri.parse(getUrl());
title.setText(item.attachment.description != null
? item.attachment.description
: url.getLastPathSegment());
title.setEllipsize(item.attachment.description != null ? TextUtils.TruncateAt.END : TextUtils.TruncateAt.MIDDLE);
domain.setText(url.getHost());
}
private void onClick(View v) {
UiUtils.openURL(itemView.getContext(), item.parentFragment.getAccountID(), getUrl());
}
private String getUrl() {
return item.attachment.remoteUrl == null ? item.attachment.url : item.attachment.remoteUrl;
}
}
}

View File

@@ -1,30 +1,48 @@
package org.joinmastodon.android.ui.displayitems;
import android.app.Activity;
import android.app.Dialog;
import android.content.Context;
import android.content.Intent;
import android.content.res.ColorStateList;
import android.graphics.Color;
import android.graphics.drawable.Drawable;
import android.os.Build;
import android.os.Bundle;
import android.os.VibrationEffect;
import android.os.Vibrator;
import android.view.HapticFeedbackConstants;
import android.view.LayoutInflater;
import android.view.MotionEvent;
import android.view.View;
import android.view.ViewConfiguration;
import android.view.ViewGroup;
import android.view.accessibility.AccessibilityNodeInfo;
import android.view.animation.AlphaAnimation;
import android.view.animation.Animation;
import android.view.animation.AnimationSet;
import android.view.animation.RotateAnimation;
import android.widget.Button;
import android.widget.FrameLayout;
import android.widget.ImageView;
import android.widget.PopupMenu;
import android.widget.TextView;
import org.joinmastodon.android.GlobalUserPreferences;
import org.joinmastodon.android.R;
import org.joinmastodon.android.api.session.AccountSession;
import org.joinmastodon.android.api.session.AccountSessionManager;
import org.joinmastodon.android.fragments.BaseStatusListFragment;
import org.joinmastodon.android.fragments.ComposeFragment;
import org.joinmastodon.android.model.Instance;
import org.joinmastodon.android.model.Status;
import org.joinmastodon.android.model.StatusPrivacy;
import org.joinmastodon.android.ui.M3AlertDialogBuilder;
import org.joinmastodon.android.ui.utils.UiUtils;
import org.parceler.Parcels;
import java.util.function.Consumer;
import me.grishka.appkit.Nav;
import me.grishka.appkit.utils.CubicBezierInterpolator;
import me.grishka.appkit.utils.V;
public class FooterStatusDisplayItem extends StatusDisplayItem{
@@ -44,10 +62,21 @@ public class FooterStatusDisplayItem extends StatusDisplayItem{
}
public static class Holder extends StatusDisplayItem.Holder<FooterStatusDisplayItem>{
private final TextView reply, boost, favorite;
private final ImageView share;
private final ColorStateList buttonColors;
private final View replyBtn, boostBtn, favoriteBtn, shareBtn;
private final TextView replies, boosts, favorites;
private final View reply, boost, favorite, share, bookmark;
private final ImageView favIcon;
private static Animation spin;
private View touchingView = null;
private boolean longClickPerformed = false;
private final Runnable longClickRunnable = () -> {
longClickPerformed = touchingView != null && touchingView.performLongClick();
if (longClickPerformed && touchingView != null) {
UiUtils.opacityIn(touchingView);
touchingView.animate().scaleX(1).scaleY(1).setInterpolator(CubicBezierInterpolator.DEFAULT).setDuration(150).start();
}
};
private final View.AccessibilityDelegate buttonAccessibilityDelegate=new View.AccessibilityDelegate(){
@Override
@@ -58,118 +87,380 @@ public class FooterStatusDisplayItem extends StatusDisplayItem{
}
};
static {
spin = new RotateAnimation(0, 360,
Animation.RELATIVE_TO_SELF, 0.5f, Animation.RELATIVE_TO_SELF,
0.5f);
spin.setDuration(400);
}
public Holder(Activity activity, ViewGroup parent){
super(activity, R.layout.display_item_footer, parent);
reply=findViewById(R.id.reply);
boost=findViewById(R.id.boost);
favorite=findViewById(R.id.favorite);
share=findViewById(R.id.share);
float[] hsb={0, 0, 0};
Color.colorToHSV(UiUtils.getThemeColor(activity, R.attr.colorM3Primary), hsb);
hsb[1]+=0.1f;
hsb[2]+=0.16f;
replies=findViewById(R.id.reply);
boosts=findViewById(R.id.boost);
favorites=findViewById(R.id.favorite);
buttonColors=new ColorStateList(new int[][]{
{android.R.attr.state_selected},
{android.R.attr.state_enabled},
{}
}, new int[]{
Color.HSVToColor(hsb),
UiUtils.getThemeColor(activity, R.attr.colorM3OnSurfaceVariant),
UiUtils.getThemeColor(activity, R.attr.colorM3OnSurfaceVariant) & 0x80FFFFFF
});
reply=findViewById(R.id.reply_btn);
boost=findViewById(R.id.boost_btn);
favorite=findViewById(R.id.favorite_btn);
share=findViewById(R.id.share_btn);
bookmark=findViewById(R.id.bookmark_btn);
favIcon=findViewById(R.id.favorite_icon);
boost.setTextColor(buttonColors);
boost.setCompoundDrawableTintList(buttonColors);
favorite.setTextColor(buttonColors);
favorite.setCompoundDrawableTintList(buttonColors);
if(Build.VERSION.SDK_INT<Build.VERSION_CODES.N){
UiUtils.fixCompoundDrawableTintOnAndroid6(reply);
UiUtils.fixCompoundDrawableTintOnAndroid6(boost);
UiUtils.fixCompoundDrawableTintOnAndroid6(favorite);
}
replyBtn=findViewById(R.id.reply_btn);
boostBtn=findViewById(R.id.boost_btn);
favoriteBtn=findViewById(R.id.favorite_btn);
shareBtn=findViewById(R.id.share_btn);
replyBtn.setOnClickListener(this::onReplyClick);
replyBtn.setAccessibilityDelegate(buttonAccessibilityDelegate);
boostBtn.setOnClickListener(this::onBoostClick);
boostBtn.setAccessibilityDelegate(buttonAccessibilityDelegate);
favoriteBtn.setOnClickListener(this::onFavoriteClick);
favoriteBtn.setAccessibilityDelegate(buttonAccessibilityDelegate);
shareBtn.setOnClickListener(this::onShareClick);
shareBtn.setAccessibilityDelegate(buttonAccessibilityDelegate);
reply.setOnTouchListener(this::onButtonTouch);
reply.setOnClickListener(this::onReplyClick);
reply.setOnLongClickListener(this::onReplyLongClick);
reply.setAccessibilityDelegate(buttonAccessibilityDelegate);
boost.setOnTouchListener(this::onButtonTouch);
boost.setOnClickListener(this::onBoostClick);
boost.setOnLongClickListener(this::onBoostLongClick);
boost.setAccessibilityDelegate(buttonAccessibilityDelegate);
favorite.setOnTouchListener(this::onButtonTouch);
favorite.setOnClickListener(this::onFavoriteClick);
favorite.setOnLongClickListener(this::onFavoriteLongClick);
favorite.setAccessibilityDelegate(buttonAccessibilityDelegate);
bookmark.setOnTouchListener(this::onButtonTouch);
bookmark.setOnClickListener(this::onBookmarkClick);
bookmark.setOnLongClickListener(this::onBookmarkLongClick);
bookmark.setAccessibilityDelegate(buttonAccessibilityDelegate);
share.setOnTouchListener(this::onButtonTouch);
share.setOnClickListener(this::onShareClick);
share.setOnLongClickListener(this::onShareLongClick);
share.setAccessibilityDelegate(buttonAccessibilityDelegate);
}
@Override
public void onBind(FooterStatusDisplayItem item){
bindButton(reply, item.status.repliesCount);
bindButton(boost, item.status.reblogsCount);
bindButton(favorite, item.status.favouritesCount);
boostBtn.setSelected(item.status.reblogged);
favoriteBtn.setSelected(item.status.favourited);
boolean isOwn=item.status.account.id.equals(AccountSessionManager.getInstance().getAccount(item.accountID).self.id);
boostBtn.setEnabled(item.status.visibility==StatusPrivacy.PUBLIC || item.status.visibility==StatusPrivacy.UNLISTED
|| (item.status.visibility==StatusPrivacy.PRIVATE && isOwn));
Drawable d=itemView.getResources().getDrawable(switch(item.status.visibility){
case PUBLIC, UNLISTED -> R.drawable.ic_boost;
case PRIVATE -> isOwn ? R.drawable.ic_boost_private : R.drawable.ic_boost_disabled_24px;
case DIRECT -> R.drawable.ic_boost_disabled_24px;
}, itemView.getContext().getTheme());
d.setBounds(0, 0, V.dp(20), V.dp(20));
boost.setCompoundDrawablesRelative(d, null, null, null);
bindText(replies, item.status.repliesCount);
bindText(boosts, item.status.reblogsCount);
bindText(favorites, item.status.favouritesCount);
// in thread view, direct descendant posts display one direct reply to themselves,
// hence in that case displaying whether there is another reply
int compareTo = item.isMainStatus || !item.hasDescendantNeighbor ? 0 : 1;
reply.setSelected(item.status.repliesCount > compareTo);
boost.setSelected(item.status.reblogged);
favorite.setSelected(item.status.favourited);
bookmark.setSelected(item.status.bookmarked);
boost.setEnabled(item.status.isReblogPermitted(item.accountID));
int nextPos = getAbsoluteAdapterPosition() + 1;
boolean nextIsWarning = item.parentFragment.getDisplayItems().size() > nextPos &&
item.parentFragment.getDisplayItems().get(nextPos) instanceof WarningFilteredStatusDisplayItem;
boolean condenseBottom = !item.isMainStatus && item.hasDescendantNeighbor &&
!nextIsWarning;
ColorStateList color=item.parentFragment.getResources().getColorStateList(
GlobalUserPreferences.likeIcon ? R.color.like_icon : R.color.favorite_icon, item.parentFragment.getContext().getTheme()
);
favIcon.setImageResource(GlobalUserPreferences.likeIcon ? R.drawable.ic_fluent_heart_24_selector : R.drawable.ic_fluent_star_24_selector);
favIcon.setImageTintList(color);
favorites.setTextColor(color);
ViewGroup.MarginLayoutParams params = (ViewGroup.MarginLayoutParams) itemView.getLayoutParams();
params.setMargins(params.leftMargin, params.topMargin, params.rightMargin,
condenseBottom ? V.dp(-5) : 0);
itemView.requestLayout();
}
private void bindButton(TextView btn, long count){
if(count>0 && !item.hideCounts){
private void bindText(TextView btn, long count){
if(AccountSessionManager.get(item.accountID).getLocalPreferences().showInteractionCounts
&& count>0 && !item.hideCounts){
btn.setText(UiUtils.abbreviateNumber(count));
btn.setCompoundDrawablePadding(V.dp(6));
btn.setCompoundDrawablePadding(V.dp(8));
}else{
btn.setText("");
btn.setCompoundDrawablePadding(0);
}
}
private boolean onButtonTouch(View v, MotionEvent event){
if(item.status.preview) return false;
boolean disabled = !v.isEnabled() || (v instanceof FrameLayout parentFrame &&
parentFrame.getChildCount() > 0 && !parentFrame.getChildAt(0).isEnabled());
int action = event.getAction();
if (action == MotionEvent.ACTION_UP || action == MotionEvent.ACTION_CANCEL) {
touchingView = null;
v.removeCallbacks(longClickRunnable);
if (!longClickPerformed) v.animate().scaleX(1).scaleY(1).setInterpolator(CubicBezierInterpolator.DEFAULT).setDuration(150).start();
if (disabled) return true;
if (action == MotionEvent.ACTION_UP && !longClickPerformed) v.performClick();
else if (!longClickPerformed) UiUtils.opacityIn(v);
} else if (action == MotionEvent.ACTION_DOWN) {
longClickPerformed = false;
touchingView = v;
v.animate().scaleX(0.85f).scaleY(0.85f).setInterpolator(CubicBezierInterpolator.DEFAULT).setDuration(75).start();
if (disabled) return true;
v.postDelayed(longClickRunnable, ViewConfiguration.getLongPressTimeout());
UiUtils.opacityOut(v);
}
return true;
}
private void onReplyClick(View v){
item.parentFragment.maybeShowPreReplySheet(item.status, ()->{
if(item.status.preview) return;
if(item.status.isRemote){
UiUtils.lookupStatus(v.getContext(),
item.status, item.accountID, null,
status -> {
UiUtils.opacityIn(v);
Bundle args=new Bundle();
args.putString("account", item.accountID);
args.putParcelable("replyTo", Parcels.wrap(status));
Nav.go(item.parentFragment.getActivity(), ComposeFragment.class, args);
}
);
return;
}
UiUtils.opacityIn(v);
Bundle args=new Bundle();
args.putString("account", item.accountID);
args.putParcelable("replyTo", Parcels.wrap(item.status));
Nav.go(item.parentFragment.getActivity(), ComposeFragment.class, args);
}
private boolean onReplyLongClick(View v) {
if(item.status.preview) return false;
if (AccountSessionManager.getInstance().getLoggedInAccounts().size() < 2) return false;
UiUtils.pickAccount(v.getContext(), item.accountID, R.string.sk_reply_as, R.drawable.ic_fluent_arrow_reply_28_regular, session -> {
Bundle args=new Bundle();
args.putString("account", item.accountID);
args.putParcelable("replyTo", Parcels.wrap(item.status));
Nav.go(item.parentFragment.getActivity(), ComposeFragment.class, args);
});
String accountID = session.getID();
args.putString("account", accountID);
UiUtils.lookupStatus(v.getContext(), item.status, accountID, item.accountID, status -> {
if (status == null) return;
args.putParcelable("replyTo", Parcels.wrap(status));
Nav.go(item.parentFragment.getActivity(), ComposeFragment.class, args);
});
}, null);
return true;
}
private void onBoostClick(View v){
if(GlobalUserPreferences.confirmBoost){
PopupMenu menu=new PopupMenu(itemView.getContext(), boost);
menu.getMenu().add(R.string.button_reblog);
menu.setOnMenuItemClickListener(item->{
doBoost();
return true;
});
menu.show();
}else{
doBoost();
if(item.status.preview) return;
if (GlobalUserPreferences.confirmBoost) {
UiUtils.opacityIn(v);
onBoostLongClick(v);
return;
}
if(item.status.isRemote){
UiUtils.lookupStatus(v.getContext(),
item.status, item.accountID, null,
status -> {
if(status == null)
return;
boost.setSelected(!status.reblogged);
vibrateForAction(boost, !status.reblogged);
AccountSessionManager.getInstance().getAccount(item.accountID).getStatusInteractionController().setReblogged(status, !status.reblogged, null, r->boostConsumer(v, r));
}
);
return;
}
boost.setSelected(!item.status.reblogged);
vibrateForAction(boost, !item.status.reblogged);
AccountSessionManager.getInstance().getAccount(item.accountID).getStatusInteractionController().setReblogged(item.status, !item.status.reblogged, null, r->boostConsumer(v, r));
}
private void doBoost(){
AccountSessionManager.getInstance().getAccount(item.accountID).getStatusInteractionController().setReblogged(item.status, !item.status.reblogged);
boost.setSelected(item.status.reblogged);
bindButton(boost, item.status.reblogsCount);
private void boostConsumer(View v, Status r) {
UiUtils.opacityIn(v);
bindText(boosts, r.reblogsCount);
}
private boolean onBoostLongClick(View v){
if(item.status.preview) return false;
Context ctx = itemView.getContext();
View menu = LayoutInflater.from(ctx).inflate(R.layout.item_boost_menu, null);
Dialog dialog = new M3AlertDialogBuilder(ctx).setView(menu).create();
AccountSession session = AccountSessionManager.getInstance().getAccount(item.accountID);
Consumer<StatusPrivacy> doReblog = (visibility) -> {
UiUtils.opacityOut(v);
if(item.status.isRemote){
UiUtils.lookupStatus(v.getContext(),
item.status, item.accountID, null,
status -> {
session.getStatusInteractionController()
.setReblogged(status, !status.reblogged, visibility, r->boostConsumer(v, r));
boost.setSelected(status.reblogged);
dialog.dismiss();
}
);
} else {
session.getStatusInteractionController()
.setReblogged(item.status, !item.status.reblogged, visibility, r->boostConsumer(v, r));
boost.setSelected(item.status.reblogged);
dialog.dismiss();
}
};
View separator = menu.findViewById(R.id.separator);
TextView reblogHeader = menu.findViewById(R.id.reblog_header);
TextView undoReblog = menu.findViewById(R.id.delete_reblog);
TextView reblogAs = menu.findViewById(R.id.reblog_as);
TextView itemPublic = menu.findViewById(R.id.vis_public);
TextView itemUnlisted = menu.findViewById(R.id.vis_unlisted);
TextView itemFollowers = menu.findViewById(R.id.vis_followers);
undoReblog.setVisibility(item.status.reblogged ? View.VISIBLE : View.GONE);
separator.setVisibility(item.status.reblogged ? View.GONE : View.VISIBLE);
reblogHeader.setVisibility(item.status.reblogged ? View.GONE : View.VISIBLE);
reblogAs.setVisibility(AccountSessionManager.getInstance().getLoggedInAccounts().size() > 1 ? View.VISIBLE : View.GONE);
itemPublic.setVisibility(item.status.reblogged ? View.GONE : View.VISIBLE);
itemUnlisted.setVisibility(item.status.reblogged ? View.GONE : View.VISIBLE);
itemFollowers.setVisibility(item.status.reblogged ? View.GONE : View.VISIBLE);
Drawable checkMark = ctx.getDrawable(R.drawable.ic_fluent_checkmark_circle_20_regular);
Drawable publicDrawable = ctx.getDrawable(R.drawable.ic_fluent_earth_24_regular);
Drawable unlistedDrawable = ctx.getDrawable(R.drawable.ic_fluent_lock_open_24_regular);
Drawable followersDrawable = ctx.getDrawable(R.drawable.ic_fluent_lock_closed_24_regular);
StatusPrivacy defaultVisibility = session.preferences != null ? session.preferences.postingDefaultVisibility : null;
itemPublic.setCompoundDrawablesWithIntrinsicBounds(publicDrawable, null, StatusPrivacy.PUBLIC.equals(defaultVisibility) ? checkMark : null, null);
itemUnlisted.setCompoundDrawablesWithIntrinsicBounds(unlistedDrawable, null, StatusPrivacy.UNLISTED.equals(defaultVisibility) ? checkMark : null, null);
itemFollowers.setCompoundDrawablesWithIntrinsicBounds(followersDrawable, null, StatusPrivacy.PRIVATE.equals(defaultVisibility) ? checkMark : null, null);
undoReblog.setOnClickListener(c->doReblog.accept(null));
itemPublic.setOnClickListener(c->doReblog.accept(StatusPrivacy.PUBLIC));
itemUnlisted.setOnClickListener(c->doReblog.accept(StatusPrivacy.UNLISTED));
itemFollowers.setOnClickListener(c->doReblog.accept(StatusPrivacy.PRIVATE));
reblogAs.setOnClickListener(c->{
dialog.dismiss();
UiUtils.pickInteractAs(v.getContext(),
item.accountID, item.status,
s -> s.reblogged,
(ic, status, consumer) -> ic.setReblogged(status, true, null, consumer),
R.string.sk_reblog_as,
R.string.sk_reblogged_as,
R.string.sk_already_reblogged,
// TODO: replace once available: https://raw.githubusercontent.com/microsoft/fluentui-system-icons/main/android/library/src/main/res/drawable/ic_fluent_arrow_repeat_all_28_regular.xml
R.drawable.ic_fluent_arrow_repeat_all_24_regular
);
});
menu.findViewById(R.id.quote).setOnClickListener(c->{
dialog.dismiss();
UiUtils.opacityIn(v);
Bundle args=new Bundle();
args.putString("account", item.accountID);
AccountSession accountSession=AccountSessionManager.getInstance().getAccount(item.accountID);
Instance instance=AccountSessionManager.getInstance().getInstanceInfo(accountSession.domain);
if(instance.pleroma == null){
StringBuilder prefilledText = new StringBuilder().append("\n\n");
String ownID = AccountSessionManager.getInstance().getAccount(item.accountID).self.id;
if (!item.status.account.id.equals(ownID)) prefilledText.append('@').append(item.status.account.acct).append(' ');
prefilledText.append(item.status.url);
args.putString("prefilledText", prefilledText.toString());
args.putInt("selectionStart", 0);
}else{
args.putParcelable("quote", Parcels.wrap(item.status));
}
Nav.go(item.parentFragment.getActivity(), ComposeFragment.class, args);
});
dialog.show();
return true;
}
private void onFavoriteClick(View v){
AccountSessionManager.getInstance().getAccount(item.accountID).getStatusInteractionController().setFavorited(item.status, !item.status.favourited);
favorite.setSelected(item.status.favourited);
bindButton(favorite, item.status.favouritesCount);
if(item.status.preview) return;
if(item.status.isRemote){
UiUtils.lookupStatus(v.getContext(),
item.status, item.accountID, null,
status -> {
if(status == null)
return;
favorite.setSelected(!status.favourited);
vibrateForAction(favorite, !status.favourited);
AccountSessionManager.getInstance().getAccount(item.accountID).getStatusInteractionController().setFavorited(status, !status.favourited, r->{
if (status.favourited && !GlobalUserPreferences.reduceMotion && !GlobalUserPreferences.likeIcon) {
v.startAnimation(spin);
}
UiUtils.opacityIn(v);
bindText(favorites, r.favouritesCount);
});
}
);
return;
}
favorite.setSelected(!item.status.favourited);
vibrateForAction(favorite, !item.status.favourited);
AccountSessionManager.getInstance().getAccount(item.accountID).getStatusInteractionController().setFavorited(item.status, !item.status.favourited, r->{
if (item.status.favourited && !GlobalUserPreferences.reduceMotion && !GlobalUserPreferences.likeIcon) {
v.startAnimation(spin);
}
UiUtils.opacityIn(v);
bindText(favorites, r.favouritesCount);
});
}
private boolean onFavoriteLongClick(View v) {
if(item.status.preview) return false;
if (AccountSessionManager.getInstance().getLoggedInAccounts().size() < 2) return false;
UiUtils.pickInteractAs(v.getContext(),
item.accountID, item.status,
s -> s.favourited,
(ic, status, consumer) -> ic.setFavorited(status, true, consumer),
R.string.sk_favorite_as,
R.string.sk_favorited_as,
R.string.sk_already_favorited,
GlobalUserPreferences.likeIcon ? R.drawable.ic_fluent_heart_28_regular : R.drawable.ic_fluent_star_28_regular
);
return true;
}
private void onBookmarkClick(View v){
if(item.status.preview) return;
if(item.status.isRemote){
UiUtils.lookupStatus(v.getContext(),
item.status, item.accountID, null,
status -> {
if(status == null)
return;
bookmark.setSelected(!status.bookmarked);
vibrateForAction(bookmark, !status.bookmarked);
AccountSessionManager.getInstance().getAccount(item.accountID).getStatusInteractionController().setBookmarked(status, !status.bookmarked, r->{
UiUtils.opacityIn(v);
});
}
);
return;
}
bookmark.setSelected(!item.status.bookmarked);
vibrateForAction(bookmark, !item.status.bookmarked);
AccountSessionManager.getInstance().getAccount(item.accountID).getStatusInteractionController().setBookmarked(item.status, !item.status.bookmarked, r->{
UiUtils.opacityIn(v);
});
}
private boolean onBookmarkLongClick(View v) {
if(item.status.preview) return false;
if (AccountSessionManager.getInstance().getLoggedInAccounts().size() < 2) return false;
UiUtils.pickInteractAs(v.getContext(),
item.accountID, item.status,
s -> s.bookmarked,
(ic, status, consumer) -> ic.setBookmarked(status, true, consumer),
R.string.sk_bookmark_as,
R.string.sk_bookmarked_as,
R.string.sk_already_bookmarked,
R.drawable.ic_fluent_bookmark_28_regular
);
return true;
}
private void onShareClick(View v){
UiUtils.openSystemShareSheet(v.getContext(), item.status.url);
if(item.status.preview) return;
UiUtils.opacityIn(v);
Intent intent=new Intent(Intent.ACTION_SEND);
intent.setType("text/plain");
intent.putExtra(Intent.EXTRA_TEXT, item.status.url);
v.getContext().startActivity(Intent.createChooser(intent, v.getContext().getString(R.string.share_toot_title)));
}
private boolean onShareLongClick(View v){
if(item.status.preview) return false;
UiUtils.copyText(v, item.status.url);
return true;
}
private int descriptionForId(int id){
@@ -179,9 +470,33 @@ public class FooterStatusDisplayItem extends StatusDisplayItem{
return R.string.button_reblog;
if(id==R.id.favorite_btn)
return R.string.button_favorite;
if(id==R.id.bookmark_btn)
return R.string.add_bookmark;
if(id==R.id.share_btn)
return R.string.button_share;
return 0;
}
private static void vibrateForAction(View view, boolean isPositive) {
if (!GlobalUserPreferences.hapticFeedback) return;
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
view.performHapticFeedback(isPositive ? HapticFeedbackConstants.CONFIRM : HapticFeedbackConstants.REJECT);
} else {
Vibrator vibrator = view.getContext().getSystemService(Vibrator.class);
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
vibrator.vibrate(VibrationEffect.createPredefined(isPositive ? VibrationEffect.EFFECT_CLICK : VibrationEffect.EFFECT_DOUBLE_CLICK));
} else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
VibrationEffect effect = isPositive
? VibrationEffect.createOneShot(75L, 128)
: VibrationEffect.createWaveform(new long[]{0L, 75L, 75L, 75L}, new int[]{0, 128, 0, 128}, -1);
vibrator.vibrate(effect);
} else {
if (isPositive) vibrator.vibrate(75L);
else vibrator.vibrate(new long[]{0L, 75L, 75L, 75L}, -1);
}
}
}
}
}

View File

@@ -8,14 +8,25 @@ import android.widget.TextView;
import org.joinmastodon.android.R;
import org.joinmastodon.android.fragments.BaseStatusListFragment;
import org.joinmastodon.android.fragments.StatusListFragment;
import org.joinmastodon.android.model.Status;
import org.joinmastodon.android.ui.drawables.SawtoothTearDrawable;
import org.joinmastodon.android.ui.utils.UiUtils;
import java.time.Instant;
import me.grishka.appkit.utils.V;
// Mind the gap!
public class GapStatusDisplayItem extends StatusDisplayItem{
public boolean loading;
public GapStatusDisplayItem(String parentID, BaseStatusListFragment parentFragment){
public GapStatusDisplayItem(String parentID, BaseStatusListFragment<?> parentFragment, Status status){
super(parentID, parentFragment);
this.status=status;
}
public String getMaxID(){
return status.hasGapAfter;
}
@Override
@@ -24,25 +35,59 @@ public class GapStatusDisplayItem extends StatusDisplayItem{
}
public static class Holder extends StatusDisplayItem.Holder<GapStatusDisplayItem>{
public final ProgressBar progress;
public final TextView text;
public final ProgressBar progressTop, progressBottom;
public final TextView textTop, gap, textBottom;
public final View top, bottom;
public Holder(Context context, ViewGroup parent){
super(context, R.layout.display_item_gap, parent);
progress=findViewById(R.id.progress);
text=findViewById(R.id.text);
itemView.setForeground(new SawtoothTearDrawable(context));
progressTop=findViewById(R.id.progress_top);
progressBottom=findViewById(R.id.progress_bottom);
textTop=findViewById(R.id.text_top);
textBottom=findViewById(R.id.text_bottom);
top=findViewById(R.id.top);
top.setOnClickListener(this::onViewClick);
bottom=findViewById(R.id.bottom);
bottom.setOnClickListener(this::onViewClick);
gap=findViewById(R.id.gap);
gap.setForeground(new SawtoothTearDrawable(context));
}
@Override
public void onBind(GapStatusDisplayItem item){
text.setVisibility(item.loading ? View.GONE : View.VISIBLE);
progress.setVisibility(item.loading ? View.VISIBLE : View.GONE);
if(!item.loading){
progressBottom.setVisibility(View.GONE);
progressTop.setVisibility(View.GONE);
textTop.setAlpha(1);
textBottom.setAlpha(1);
}
top.setClickable(!item.loading);
bottom.setClickable(!item.loading);
Status next=!(item.parentFragment instanceof StatusListFragment) ? null : getNextVisibleDisplayItem(i->{
Status s=((StatusListFragment) item.parentFragment).getStatusByID(i.parentID);
return s!=null && !s.fromStatusCreated;
})
.map(i->((StatusListFragment) item.parentFragment).getStatusByID(i.parentID))
.orElse(null);
bottom.setVisibility(next==null ? View.GONE : View.VISIBLE);
Instant dateBelow=next!=null ? next.createdAt : null;
String text=dateBelow!=null && item.status.createdAt!=null && dateBelow.isBefore(item.status.createdAt)
? UiUtils.formatPeriodBetween(item.parentFragment.getContext(), dateBelow, item.status.createdAt)
: null;
gap.setText(text);
int p=text==null ? V.dp(6) : V.dp(20);
gap.setPadding(p, p, p, p);
}
private void onViewClick(View v){
if(item.loading) return;
boolean isTop=v==top;
UiUtils.opacityOut(isTop ? textTop : textBottom);
V.setVisibilityAnimated((isTop ? progressTop : progressBottom), View.VISIBLE);
item.parentFragment.onGapClick(this, isTop);
}
@Override
public void onClick(){
item.parentFragment.onGapClick(this);
}
public void onClick(){}
}
}

View File

@@ -3,17 +3,17 @@ package org.joinmastodon.android.ui.displayitems;
import android.annotation.SuppressLint;
import android.app.Activity;
import android.app.ProgressDialog;
import android.content.ClipData;
import android.content.ClipboardManager;
import android.graphics.Paint;
import android.graphics.Typeface;
import android.graphics.drawable.Animatable;
import android.graphics.drawable.Drawable;
import android.os.Build;
import android.os.Bundle;
import android.os.Parcel;
import android.text.SpannableStringBuilder;
import android.text.TextUtils;
import android.view.Menu;
import android.view.MenuItem;
import android.view.SubMenu;
import android.view.View;
import android.view.ViewGroup;
import android.widget.ImageView;
@@ -24,17 +24,26 @@ import android.widget.Toast;
import org.joinmastodon.android.GlobalUserPreferences;
import org.joinmastodon.android.R;
import org.joinmastodon.android.api.requests.accounts.GetAccountRelationships;
import org.joinmastodon.android.api.requests.announcements.DismissAnnouncement;
import org.joinmastodon.android.api.requests.statuses.CreateStatus;
import org.joinmastodon.android.api.requests.statuses.GetStatusSourceText;
import org.joinmastodon.android.api.session.AccountSession;
import org.joinmastodon.android.api.session.AccountSessionManager;
import org.joinmastodon.android.fragments.AddAccountToListsFragment;
import org.joinmastodon.android.fragments.BaseStatusListFragment;
import org.joinmastodon.android.fragments.ComposeFragment;
import org.joinmastodon.android.fragments.ListsFragment;
import org.joinmastodon.android.fragments.NotificationsListFragment;
import org.joinmastodon.android.fragments.ProfileFragment;
import org.joinmastodon.android.fragments.ThreadFragment;
import org.joinmastodon.android.fragments.report.ReportReasonChoiceFragment;
import org.joinmastodon.android.model.Account;
import org.joinmastodon.android.model.Attachment;
import org.joinmastodon.android.model.Announcement;
import org.joinmastodon.android.model.Mention;
import org.joinmastodon.android.model.Notification;
import org.joinmastodon.android.model.Relationship;
import org.joinmastodon.android.model.ScheduledStatus;
import org.joinmastodon.android.model.Status;
import org.joinmastodon.android.model.StatusPrivacy;
import org.joinmastodon.android.ui.OutlineProviders;
import org.joinmastodon.android.ui.text.HtmlParser;
import org.joinmastodon.android.ui.utils.CustomEmojiHelper;
@@ -42,9 +51,14 @@ import org.joinmastodon.android.ui.utils.UiUtils;
import org.parceler.Parcels;
import java.time.Instant;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.time.format.FormatStyle;
import java.util.Collections;
import java.util.List;
import java.util.Locale;
import java.util.Objects;
import java.util.function.Consumer;
import androidx.annotation.LayoutRes;
import me.grishka.appkit.Nav;
@@ -54,6 +68,7 @@ import me.grishka.appkit.api.ErrorResponse;
import me.grishka.appkit.imageloader.ImageLoaderViewHolder;
import me.grishka.appkit.imageloader.requests.ImageLoaderRequest;
import me.grishka.appkit.imageloader.requests.UrlImageLoaderRequest;
import me.grishka.appkit.utils.CubicBezierInterpolator;
import me.grishka.appkit.utils.V;
public class HeaderStatusDisplayItem extends StatusDisplayItem{
@@ -63,34 +78,45 @@ public class HeaderStatusDisplayItem extends StatusDisplayItem{
private String accountID;
private CustomEmojiHelper emojiHelper=new CustomEmojiHelper();
private SpannableStringBuilder parsedName;
public final Status status;
private boolean hasVisibilityToggle;
public boolean hasVisibilityToggle;
boolean needBottomPadding;
private String extraText;
private CharSequence extraText;
private Notification notification;
private ScheduledStatus scheduledStatus;
private Announcement announcement;
private Consumer<String> consumeReadAnnouncement;
public HeaderStatusDisplayItem(String parentID, Account user, Instant createdAt, BaseStatusListFragment parentFragment, String accountID, Status status, String extraText){
public HeaderStatusDisplayItem(String parentID, Account user, Instant createdAt, BaseStatusListFragment parentFragment, String accountID, Status status, CharSequence extraText, Notification notification, ScheduledStatus scheduledStatus){
super(parentID, parentFragment);
AccountSession session = AccountSessionManager.get(accountID);
user=scheduledStatus != null ? session.self : user;
this.user=user;
this.createdAt=createdAt;
avaRequest=new UrlImageLoaderRequest(GlobalUserPreferences.playGifs ? user.avatar : user.avatarStatic, V.dp(50), V.dp(50));
avaRequest=new UrlImageLoaderRequest(
TextUtils.isEmpty(user.avatar) ? session.getDefaultAvatarUrl() :
GlobalUserPreferences.playGifs ? user.avatar : user.avatarStatic,
V.dp(50), V.dp(50));
this.accountID=accountID;
parsedName=new SpannableStringBuilder(user.displayName);
parsedName=new SpannableStringBuilder(user.getDisplayName());
this.status=status;
this.notification=notification;
this.scheduledStatus=scheduledStatus;
if(AccountSessionManager.get(accountID).getLocalPreferences().customEmojiInNames)
HtmlParser.parseCustomEmoji(parsedName, user.emojis);
emojiHelper.setText(parsedName);
if(status!=null){
hasVisibilityToggle=status.sensitive || !TextUtils.isEmpty(status.spoilerText);
if(!hasVisibilityToggle && !status.mediaAttachments.isEmpty()){
for(Attachment att:status.mediaAttachments){
if(att.type!=Attachment.Type.AUDIO){
hasVisibilityToggle=true;
break;
}
}
}
// visibility toggle can't do much for non-"image" attachments
hasVisibilityToggle=status.mediaAttachments.stream().anyMatch(m -> m.type.isImage());
}
this.extraText=extraText;
emojiHelper.addText(extraText);
}
public static HeaderStatusDisplayItem fromAnnouncement(Announcement a, Status fakeStatus, Account instanceUser, BaseStatusListFragment parentFragment, String accountID, Consumer<String> consumeReadID) {
HeaderStatusDisplayItem item = new HeaderStatusDisplayItem(a.id, instanceUser, a.startsAt, parentFragment, accountID, fakeStatus, null, null, null);
item.announcement = a;
item.consumeReadAnnouncement = consumeReadID;
return item;
}
@Override
@@ -112,9 +138,12 @@ public class HeaderStatusDisplayItem extends StatusDisplayItem{
}
public static class Holder extends StatusDisplayItem.Holder<HeaderStatusDisplayItem> implements ImageLoaderViewHolder{
private final TextView name, timeAndUsername, extraText;
private final ImageView avatar, more;
private final TextView name, time, username, extraText;
private final View collapseBtn, timeUsernameSeparator;
private final ImageView avatar, more, visibility, deleteNotification, unreadIndicator, markAsRead, collapseBtnIcon, botIcon;
private final PopupMenu optionsMenu;
private Relationship relationship;
private APIRequest<?> currentRelationshipRequest;
public Holder(Activity activity, ViewGroup parent){
this(activity, R.layout.display_item_header, parent);
@@ -123,14 +152,30 @@ public class HeaderStatusDisplayItem extends StatusDisplayItem{
protected Holder(Activity activity, @LayoutRes int layout, ViewGroup parent){
super(activity, layout, parent);
name=findViewById(R.id.name);
timeAndUsername=findViewById(R.id.time_and_username);
time=findViewById(R.id.time);
username=findViewById(R.id.username);
botIcon=findViewById(R.id.bot_icon);
timeUsernameSeparator=findViewById(R.id.separator);
avatar=findViewById(R.id.avatar);
more=findViewById(R.id.more);
visibility=findViewById(R.id.visibility);
deleteNotification=findViewById(R.id.delete_notification);
unreadIndicator=findViewById(R.id.unread_indicator);
markAsRead=findViewById(R.id.mark_as_read);
collapseBtn=findViewById(R.id.collapse_btn);
collapseBtnIcon=findViewById(R.id.collapse_btn_icon);
extraText=findViewById(R.id.extra_text);
avatar.setOnClickListener(this::onAvaClick);
avatar.setOutlineProvider(OutlineProviders.roundedRect(10));
avatar.setOutlineProvider(OutlineProviders.roundedRect(12));
avatar.setClipToOutline(true);
more.setOnClickListener(this::onMoreClick);
visibility.setOnClickListener(v->item.parentFragment.onVisibilityIconClick(this));
deleteNotification.setOnClickListener(v->UiUtils.confirmDeleteNotification(activity, item.parentFragment.getAccountID(), item.notification, ()->{
if (item.parentFragment instanceof NotificationsListFragment fragment) {
fragment.removeNotification(item.notification);
}
}));
collapseBtn.setOnClickListener(l -> item.parentFragment.onToggleExpanded(item.status, getItemID()));
optionsMenu=new PopupMenu(activity, more);
optionsMenu.inflate(R.menu.post);
@@ -138,13 +183,33 @@ public class HeaderStatusDisplayItem extends StatusDisplayItem{
optionsMenu.getMenu().setGroupDividerEnabled(true);
optionsMenu.setOnMenuItemClickListener(menuItem->{
Account account=item.user;
Relationship relationship=item.parentFragment.getRelationship(account.id);
int id=menuItem.getItemId();
if(id==R.id.edit){
if(id==R.id.edit || id==R.id.delete_and_redraft){
final Bundle args=new Bundle();
args.putString("account", item.parentFragment.getAccountID());
args.putParcelable("editStatus", Parcels.wrap(item.status));
if(TextUtils.isEmpty(item.status.content) && TextUtils.isEmpty(item.status.spoilerText)){
boolean redraft = id == R.id.delete_and_redraft;
if (redraft) {
args.putBoolean("redraftStatus", true);
if (item.parentFragment instanceof ThreadFragment thread && !thread.isItemEnabled(item.status.id)) {
// ("enabled" = clickable; opened status is not clickable)
// request navigation to the re-drafted status if status is currently opened
args.putBoolean("navigateToStatus", true);
}
}
boolean isPixelfed = item.parentFragment.isInstancePixelfed();
boolean textEmpty = TextUtils.isEmpty(item.status.content) && !item.status.hasSpoiler();
if(!redraft && (isPixelfed || textEmpty)){
// pixelfed doesn't support /statuses/:id/source :/
if (isPixelfed) {
args.putString("sourceText", HtmlParser.text(item.status.content));
args.putString("sourceSpoiler", item.status.spoilerText);
}
Nav.go(item.parentFragment.getActivity(), ComposeFragment.class, args);
}else if(item.scheduledStatus!=null){
args.putString("sourceText", item.status.text);
args.putString("sourceSpoiler", item.status.spoilerText);
args.putParcelable("scheduledStatus", Parcels.wrap(item.scheduledStatus));
Nav.go(item.parentFragment.getActivity(), ComposeFragment.class, args);
}else{
new GetStatusSourceText(item.status.id)
@@ -153,7 +218,16 @@ public class HeaderStatusDisplayItem extends StatusDisplayItem{
public void onSuccess(GetStatusSourceText.Response result){
args.putString("sourceText", result.text);
args.putString("sourceSpoiler", result.spoilerText);
Nav.go(item.parentFragment.getActivity(), ComposeFragment.class, args);
if(result.contentType!=null){
args.putString("sourceContentType", result.contentType.name());
}
if(redraft){
UiUtils.confirmDeletePost(item.parentFragment.getActivity(), item.parentFragment.getAccountID(), item.status, s->{
Nav.go(item.parentFragment.getActivity(), ComposeFragment.class, args);
}, true);
}else{
Nav.go(item.parentFragment.getActivity(), ComposeFragment.class, args);
}
}
@Override
@@ -165,9 +239,17 @@ public class HeaderStatusDisplayItem extends StatusDisplayItem{
.exec(item.parentFragment.getAccountID());
}
}else if(id==R.id.delete){
UiUtils.confirmDeletePost(item.parentFragment.getActivity(), item.parentFragment.getAccountID(), item.status, s->{});
if (item.scheduledStatus != null) {
UiUtils.confirmDeleteScheduledPost(item.parentFragment.getActivity(), item.parentFragment.getAccountID(), item.scheduledStatus, ()->{});
} else {
UiUtils.confirmDeletePost(item.parentFragment.getActivity(), item.parentFragment.getAccountID(), item.status, s->{}, false);
}
}else if(id==R.id.pin || id==R.id.unpin) {
UiUtils.confirmPinPost(item.parentFragment.getActivity(), item.parentFragment.getAccountID(), item.status, !item.status.pinned, s->{});
}else if(id==R.id.mute){
UiUtils.confirmToggleMuteUser(item.parentFragment.getActivity(), item.parentFragment.getAccountID(), account, relationship!=null && relationship.muting, r->{});
}else if (id==R.id.mute_conversation || id==R.id.unmute_conversation) {
UiUtils.confirmToggleMuteConversation(item.parentFragment.getActivity(), item.parentFragment.getAccountID(), item.status, ()->{});
}else if(id==R.id.block){
UiUtils.confirmToggleBlockUser(item.parentFragment.getActivity(), item.parentFragment.getAccountID(), account, relationship!=null && relationship.blocking, r->{});
}else if(id==R.id.report){
@@ -179,6 +261,8 @@ public class HeaderStatusDisplayItem extends StatusDisplayItem{
Nav.go(item.parentFragment.getActivity(), ReportReasonChoiceFragment.class, args);
}else if(id==R.id.open_in_browser){
UiUtils.launchWebBrowser(activity, item.status.url);
}else if(id==R.id.copy_link){
UiUtils.copyText(parent, item.status.url);
}else if(id==R.id.follow){
if(relationship==null)
return true;
@@ -191,49 +275,143 @@ public class HeaderStatusDisplayItem extends StatusDisplayItem{
else
progress.dismiss();
}, rel->{
item.parentFragment.putRelationship(account.id, rel);
relationship=rel;
Toast.makeText(activity, activity.getString(rel.following ? R.string.followed_user : rel.requested ? R.string.following_user_requested : R.string.unfollowed_user, account.getDisplayUsername()), Toast.LENGTH_SHORT).show();
});
}else if(id==R.id.block_domain){
UiUtils.confirmToggleBlockDomain(activity, item.parentFragment.getAccountID(), account.getDomain(), relationship!=null && relationship.domainBlocking, ()->{});
}else if(id==R.id.bookmark){
AccountSessionManager.getInstance().getAccount(item.accountID).getStatusInteractionController().setBookmarked(item.status, !item.status.bookmarked);
}else if(id==R.id.manage_user_lists){
final Bundle args=new Bundle();
args.putString("account", item.parentFragment.getAccountID());
args.putString("profileAccount", account.id);
args.putString("profileDisplayUsername", account.getDisplayUsername());
Nav.go(item.parentFragment.getActivity(), ListsFragment.class, args);
}else if(id==R.id.share){
UiUtils.openSystemShareSheet(activity, item.status.url);
}else if(id==R.id.translate){
item.parentFragment.togglePostTranslation(item.status, item.parentID);
}else if(id==R.id.add_to_list){
Bundle args=new Bundle();
args.putString("account", item.parentFragment.getAccountID());
args.putParcelable("targetAccount", Parcels.wrap(account));
Nav.go(activity, AddAccountToListsFragment.class, args);
}else if(id==R.id.copy_link){
activity.getSystemService(ClipboardManager.class).setPrimaryClip(ClipData.newPlainText(null, item.status.url));
UiUtils.maybeShowTextCopiedToast(activity);
}
return true;
});
UiUtils.enablePopupMenuIcons(activity, optionsMenu);
}
@SuppressLint("SetTextI18n")
@Override
public void onBind(HeaderStatusDisplayItem item){
name.setText(item.parsedName);
String time;
if(item.status==null || item.status.editedAt==null)
String time = null;
if (item.scheduledStatus!=null) {
if (item.scheduledStatus.scheduledAt.isAfter(CreateStatus.DRAFTS_AFTER_INSTANT)) {
time = item.parentFragment.getString(R.string.sk_draft);
} else {
DateTimeFormatter formatter = DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM).withLocale(Locale.getDefault());
time = item.scheduledStatus.scheduledAt.atZone(ZoneId.systemDefault()).format(formatter);
}
} else if(item.status==null || item.status.editedAt==null)
time=UiUtils.formatRelativeTimestamp(itemView.getContext(), item.createdAt);
else
else if (item.status != null && item.status.editedAt != null)
time=item.parentFragment.getString(R.string.edited_timestamp, UiUtils.formatRelativeTimestamp(itemView.getContext(), item.status.editedAt));
timeAndUsername.setText(time+" · @"+item.user.acct);
this.username.setText(item.user.getDisplayUsername());
this.timeUsernameSeparator.setVisibility(time==null ? View.GONE : View.VISIBLE);
this.time.setVisibility(time==null ? View.GONE : View.VISIBLE);
if(time!=null) this.time.setText(time);
botIcon.setVisibility(item.user.bot ? View.VISIBLE : View.GONE);
botIcon.setColorFilter(username.getCurrentTextColor());
deleteNotification.setVisibility(GlobalUserPreferences.enableDeleteNotifications && item.notification!=null && !item.inset ? View.VISIBLE : View.GONE);
visibility.setVisibility(item.hasVisibilityToggle ? View.VISIBLE : View.GONE);
if (item.hasVisibilityToggle){
boolean visible = item.status.sensitiveRevealed && (!item.status.hasSpoiler() || item.status.spoilerRevealed);
visibility.setAlpha(visible ? 1 : 0f);
visibility.setScaleY(visible ? 1 : 0.8f);
visibility.setScaleX(visible ? 1 : 0.8f);
visibility.setEnabled(visible);
}
itemView.setPadding(itemView.getPaddingLeft(), itemView.getPaddingTop(), itemView.getPaddingRight(), item.needBottomPadding ? V.dp(16) : 0);
if(TextUtils.isEmpty(item.extraText)){
extraText.setVisibility(View.GONE);
if (item.status != null) {
boolean displayPronouns=item.parentFragment instanceof ThreadFragment ? GlobalUserPreferences.displayPronounsInThreads : GlobalUserPreferences.displayPronounsInTimelines;
UiUtils.setExtraTextInfo(item.parentFragment.getContext(), extraText, displayPronouns, item.status.visibility==StatusPrivacy.DIRECT, item.status.localOnly || item.status.visibility==StatusPrivacy.LOCAL, item.status.account);
}
}else{
extraText.setVisibility(View.VISIBLE);
extraText.setText(item.extraText);
}
more.setVisibility(item.inset ? View.GONE : View.VISIBLE);
more.setVisibility(item.announcement != null || item.inset ||
(item.notification != null && item.notification.report != null)
? View.GONE : View.VISIBLE);
more.setOnClickListener(this::onMoreClick);
avatar.setClickable(!item.inset);
avatar.setContentDescription(item.parentFragment.getString(R.string.avatar_description, item.user.acct));
if(currentRelationshipRequest!=null){
currentRelationshipRequest.cancel();
}
relationship=null;
if (item.announcement != null) {
int vis = item.announcement.read ? View.GONE : View.VISIBLE;
V.setVisibilityAnimated(unreadIndicator, vis);
V.setVisibilityAnimated(markAsRead, vis);
markAsRead.setEnabled(!item.announcement.read);
markAsRead.setOnClickListener(v -> {
if (item.announcement.read) return;
new DismissAnnouncement(item.announcement.id).setCallback(new Callback<>() {
@Override
public void onSuccess(Object o) {
item.consumeReadAnnouncement.accept(item.announcement.id);
item.announcement.read = true;
if (item.parentFragment.getActivity() == null) return;
rebind();
}
@Override
public void onError(ErrorResponse error) {
error.showToast(item.parentFragment.getActivity());
}
}).exec(item.accountID);
});
} else {
markAsRead.setVisibility(View.GONE);
}
bindCollapseButton();
itemView.setPaddingRelative(itemView.getPaddingStart(), itemView.getPaddingTop(),
item.inset ? V.dp(10) : V.dp(4), itemView.getPaddingBottom());
}
public void bindCollapseButton(){
boolean expandable=item.status!=null && item.status.textExpandable;
collapseBtn.setVisibility(expandable ? View.VISIBLE : View.GONE);
if(expandable) {
bindCollapseButtonText();
collapseBtnIcon.setScaleY(item.status.textExpanded ? -1 : 1);
}
}
private void bindCollapseButtonText(){
String collapseText = item.parentFragment.getString(item.status.textExpanded ? R.string.sk_collapse : R.string.sk_expand);
collapseBtn.setContentDescription(collapseText);
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) collapseBtn.setTooltipText(collapseText);
}
public void animateExpandToggle(){
bindCollapseButtonText();
collapseBtnIcon.animate().scaleY(item.status.textExpanded ? -1 : 1).start();
}
public void animateVisibilityToggle(boolean visible){
visibility.animate()
.alpha(visible ? 1 : 0)
.scaleX(visible ? 1 : 0.8f)
.scaleY(visible ? 1 : 0.8f)
.setInterpolator(CubicBezierInterpolator.DEFAULT)
.start();
visibility.setEnabled(visible);
}
@Override
@@ -258,63 +436,180 @@ public class HeaderStatusDisplayItem extends StatusDisplayItem{
}
private void onAvaClick(View v){
if (TextUtils.isEmpty(item.user.url))
return;
if (item.announcement != null) {
UiUtils.openURL(item.parentFragment.getActivity(), item.parentFragment.getAccountID(), item.user.url);
return;
}
Bundle args=new Bundle();
if(item.status != null && item.status.isRemote){
UiUtils.lookupAccount(v.getContext(), item.status.account, item.accountID, null, account -> {
args.putString("account", item.accountID);
args.putParcelable("profileAccount", Parcels.wrap(account));
Nav.go(item.parentFragment.getActivity(), ProfileFragment.class, args);
});
return;
}
args.putString("account", item.accountID);
args.putParcelable("profileAccount", Parcels.wrap(item.user));
Nav.go(item.parentFragment.getActivity(), ProfileFragment.class, args);
}
private void onMoreClick(View v){
if(item.status.preview) return;
updateOptionsMenu();
optionsMenu.show();
if(relationship==null && currentRelationshipRequest==null){
currentRelationshipRequest=new GetAccountRelationships(Collections.singletonList(item.user.id))
.setCallback(new Callback<>(){
@Override
public void onSuccess(List<Relationship> result){
if(!result.isEmpty()){
relationship=result.get(0);
updateOptionsMenu();
}
currentRelationshipRequest=null;
}
@Override
public void onError(ErrorResponse error){
currentRelationshipRequest=null;
}
})
.exec(item.parentFragment.getAccountID());
}
}
private void updateOptionsMenu(){
if(item.parentFragment.getActivity()==null)
return;
if (item.announcement != null) return;
boolean hasMultipleAccounts = AccountSessionManager.getInstance().getLoggedInAccounts().size() > 1;
Account account=item.user;
Relationship relationship=item.parentFragment.getRelationship(account.id);
Menu menu=optionsMenu.getMenu();
boolean isOwnPost=AccountSessionManager.getInstance().isSelf(item.parentFragment.getAccountID(), account);
boolean canTranslate=item.status!=null && item.status.getContentStatus().isEligibleForTranslation();
MenuItem translate=menu.findItem(R.id.translate);
translate.setVisible(canTranslate);
if(canTranslate){
if(item.status.translationState==Status.TranslationState.SHOWN)
translate.setTitle(R.string.translation_show_original);
else
translate.setTitle(item.parentFragment.getString(R.string.translate_post, Locale.forLanguageTag(item.status.getContentStatus().language).getDisplayLanguage()));
MenuItem openWithAccounts = menu.findItem(R.id.open_with_account);
SubMenu accountsMenu = openWithAccounts != null ? openWithAccounts.getSubMenu() : null;
if (hasMultipleAccounts && accountsMenu != null) {
openWithAccounts.setVisible(true);
accountsMenu.clear();
UiUtils.populateAccountsMenu(item.accountID, accountsMenu, s-> UiUtils.openURL(
item.parentFragment.getActivity(), s.getID(), item.status.url, false
));
} else if (openWithAccounts != null) {
openWithAccounts.setVisible(false);
}
String username = account.getShortUsername();
boolean isOwnPost=AccountSessionManager.getInstance().isSelf(item.parentFragment.getAccountID(), account);
boolean isPostScheduled=item.scheduledStatus!=null;
menu.findItem(R.id.open_with_account).setVisible(!isPostScheduled && hasMultipleAccounts);
menu.findItem(R.id.edit).setVisible(item.status!=null && isOwnPost);
menu.findItem(R.id.delete).setVisible(item.status!=null && isOwnPost);
menu.findItem(R.id.open_in_browser).setVisible(item.status!=null);
menu.findItem(R.id.delete_and_redraft).setVisible(!isPostScheduled && item.status!=null && isOwnPost);
menu.findItem(R.id.pin).setVisible(!isPostScheduled && item.status!=null && isOwnPost && !item.status.pinned);
menu.findItem(R.id.unpin).setVisible(!isPostScheduled && item.status!=null && isOwnPost && item.status.pinned);
menu.findItem(R.id.mute_conversation).setVisible((item.status!=null && !item.status.muted && !isPostScheduled) && (isOwnPost || item.status.mentions.stream().anyMatch(m->{
if(m==null)
return false;
return AccountSessionManager.get(item.parentFragment.getAccountID()).self.id.equals(m.id) ||
AccountSessionManager.get(item.parentFragment.getAccountID()).self.getFullyQualifiedName().equals(m.username) ||
AccountSessionManager.get(item.parentFragment.getAccountID()).self.acct.equals(m.acct);
})));
menu.findItem(R.id.unmute_conversation).setVisible(item.status!=null && item.status.muted);
menu.findItem(R.id.open_in_browser).setVisible(!isPostScheduled && item.status!=null);
menu.findItem(R.id.copy_link).setVisible(!isPostScheduled && item.status!=null);
MenuItem blockDomain=menu.findItem(R.id.block_domain);
MenuItem mute=menu.findItem(R.id.mute);
MenuItem block=menu.findItem(R.id.block);
MenuItem report=menu.findItem(R.id.report);
MenuItem follow=menu.findItem(R.id.follow);
MenuItem manageUserLists = menu.findItem(R.id.manage_user_lists);
/* disabled in megalodon: add/remove bookmark is already available through status footer
MenuItem bookmark=menu.findItem(R.id.bookmark);
bookmark.setVisible(false);
if(item.status!=null){
bookmark.setVisible(true);
bookmark.setTitle(item.status.bookmarked ? R.string.remove_bookmark : R.string.add_bookmark);
}else{
bookmark.setVisible(false);
}
if(isOwnPost){
*/
if(isPostScheduled || isOwnPost){
mute.setVisible(false);
block.setVisible(false);
report.setVisible(false);
follow.setVisible(false);
blockDomain.setVisible(false);
manageUserLists.setVisible(false);
}else{
mute.setVisible(true);
block.setVisible(true);
// hiding when following to keep menu item count equal (trading it for user lists)
block.setVisible(relationship == null || !relationship.following);
report.setVisible(true);
follow.setVisible(relationship==null || relationship.following || (!relationship.blocking && !relationship.blockedBy && !relationship.domainBlocking && !relationship.muting));
mute.setTitle(item.parentFragment.getString(relationship!=null && relationship.muting ? R.string.unmute_user : R.string.mute_user, account.displayName));
block.setTitle(item.parentFragment.getString(relationship!=null && relationship.blocking ? R.string.unblock_user : R.string.block_user, account.displayName));
report.setTitle(item.parentFragment.getString(R.string.report_user, account.displayName));
follow.setTitle(item.parentFragment.getString(relationship!=null && relationship.following ? R.string.unfollow_user : R.string.follow_user, account.displayName));
mute.setTitle(item.parentFragment.getString(relationship!=null && relationship.muting ? R.string.unmute_user : R.string.mute_user, username));
mute.setIcon(relationship!=null && relationship.muting ? R.drawable.ic_fluent_speaker_0_24_regular : R.drawable.ic_fluent_speaker_off_24_regular);
UiUtils.insetPopupMenuIcon(item.parentFragment.getContext(), mute);
block.setTitle(item.parentFragment.getString(relationship!=null && relationship.blocking ? R.string.unblock_user : R.string.block_user, username));
report.setTitle(item.parentFragment.getString(R.string.report_user, username));
// disabled in megalodon. domain blocks from a post clutters the context menu and looks out of place
// if(!account.isLocal()){
// blockDomain.setVisible(true);
// blockDomain.setTitle(item.parentFragment.getString(relationship!=null && relationship.domainBlocking ? R.string.unblock_domain : R.string.block_domain, account.getDomain()));
// }else{
blockDomain.setVisible(false);
// }
boolean following = relationship!=null && relationship.following;
follow.setTitle(item.parentFragment.getString(following ? R.string.unfollow_user : R.string.follow_user, username));
follow.setIcon(following ? R.drawable.ic_fluent_person_delete_24_regular : R.drawable.ic_fluent_person_add_24_regular);
manageUserLists.setVisible(relationship != null && relationship.following);
manageUserLists.setTitle(item.parentFragment.getString(R.string.sk_lists_with_user, username));
// ic_fluent_person_add_24_regular actually has a width of 25dp -.-
UiUtils.insetPopupMenuIcon(item.parentFragment.getContext(), follow, following ? 0 : V.dp(-1));
}
workaroundChangingMenuItemWidths(menu, username);
}
// ugliest piece of code you'll see in a while: i measure the menu items' text widths to
// determine the biggest one, because it's probably not being displayed at first
// (before the relationship loaded). i take the largest one's size and add a space to the
// last item ("open in browser") until it takes up as much space as the largest item.
// goal: no more ugly ellipsis after the relationship loads in when opening the context menu
// of a post
private void workaroundChangingMenuItemWidths(Menu menu, String username) {
String openInBrowserText = item.parentFragment.getString(R.string.open_in_browser);
if (relationship == null) {
float largestWidth = 0;
Paint paint = new Paint();
paint.setTypeface(Typeface.create("sans-serif", Typeface.NORMAL));
String[] otherStrings = new String[] {
item.parentFragment.getString(R.string.unfollow_user, username),
item.parentFragment.getString(R.string.unblock_user, username),
item.parentFragment.getString(R.string.unmute_user, username),
item.parentFragment.getString(R.string.sk_lists_with_user, username),
};
for (int i = 0; i < menu.size(); i++) {
MenuItem item = menu.getItem(i);
if (item.getItemId() == R.id.open_in_browser || !item.isVisible()) continue;
float width = paint.measureText(menu.getItem(i).getTitle().toString());
if (width > largestWidth) largestWidth = width;
}
for (String str : otherStrings) {
float width = paint.measureText(str);
if (width > largestWidth) largestWidth = width;
}
float textWidth = paint.measureText(openInBrowserText);
float missingWidth = Math.max(0, largestWidth - textWidth);
float singleSpaceWidth = paint.measureText("");
int howManySpaces = (int) Math.ceil(missingWidth / singleSpaceWidth);
String enlargedText = openInBrowserText + "".repeat(howManySpaces);
menu.findItem(R.id.open_in_browser).setTitle(enlargedText);
} else {
menu.findItem(R.id.open_in_browser).setTitle(openInBrowserText);
}
menu.findItem(R.id.add_to_list).setVisible(relationship!=null && relationship.following);
}
}
}

View File

@@ -8,6 +8,7 @@ import android.net.Uri;
import android.text.TextUtils;
import android.view.View;
import android.view.ViewGroup;
import android.widget.FrameLayout;
import android.widget.ImageView;
import android.widget.TextView;
@@ -22,15 +23,15 @@ import org.joinmastodon.android.ui.utils.UiUtils;
import me.grishka.appkit.imageloader.ImageLoaderViewHolder;
import me.grishka.appkit.imageloader.requests.ImageLoaderRequest;
import me.grishka.appkit.imageloader.requests.UrlImageLoaderRequest;
import me.grishka.appkit.utils.V;
public class LinkCardStatusDisplayItem extends StatusDisplayItem{
private final Status status;
private final UrlImageLoaderRequest imgRequest;
public LinkCardStatusDisplayItem(String parentID, BaseStatusListFragment parentFragment, Status status){
public LinkCardStatusDisplayItem(String parentID, BaseStatusListFragment parentFragment, Status status, boolean showImagePreview){
super(parentID, parentFragment);
this.status=status;
if(status.card.image!=null)
if(status.card.image!=null && showImagePreview)
imgRequest=new UrlImageLoaderRequest(status.card.image, 1000, 1000);
else
imgRequest=null;
@@ -101,23 +102,36 @@ public class LinkCardStatusDisplayItem extends StatusDisplayItem{
photo.setBackground(null);
photo.setImageTintList(null);
crossfadeDrawable.setSize(card.width, card.height);
if (card.width > 0) {
// akkoma servers don't provide width and height
crossfadeDrawable.setSize(card.width, card.height);
} else {
crossfadeDrawable.setSize(itemView.getWidth(), itemView.getHeight());
}
crossfadeDrawable.setBlurhashDrawable(card.blurhashPlaceholder);
crossfadeDrawable.setCrossfadeAlpha(0f);
photo.setImageDrawable(null);
photo.setImageDrawable(crossfadeDrawable);
photo.setVisibility(View.VISIBLE);
didClear=false;
}else{
photo.setBackgroundColor(UiUtils.getThemeColor(itemView.getContext(), R.attr.colorM3SurfaceVariant));
photo.setImageTintList(ColorStateList.valueOf(UiUtils.getThemeColor(itemView.getContext(), R.attr.colorM3Outline)));
photo.setScaleType(ImageView.ScaleType.CENTER);
photo.setImageResource(R.drawable.ic_feed_48px);
} else {
photo.setVisibility(View.GONE);
}
// if there's no image, we don't want to cover the inset borders
FrameLayout.LayoutParams params=(FrameLayout.LayoutParams) inner.getLayoutParams();
int margin=item.inset && item.imgRequest == null ? V.dp(1) : 0;
params.setMargins(margin, 0, margin, margin);
boolean insetAndLast=item.inset && isLastDisplayItemForStatus();
inner.setClipToOutline(insetAndLast);
inner.setOutlineProvider(insetAndLast ? OutlineProviders.bottomRoundedRect(12) : null);
}
@Override
public void setImage(int index, Drawable drawable){
crossfadeDrawable.setImageDrawable(drawable);
if(didClear)
if(didClear && item.status.spoilerRevealed)
crossfadeDrawable.animateAlpha(0f);
Card card=item.status.card;
// Make sure the image is not stretched if the server returned wrong dimensions
@@ -134,7 +148,7 @@ public class LinkCardStatusDisplayItem extends StatusDisplayItem{
}
private void onClick(View v){
UiUtils.openURL(itemView.getContext(), item.parentFragment.getAccountID(), item.status.card.url, item.status);
UiUtils.openURL(itemView.getContext(), item.parentFragment.getAccountID(), item.status.card.url);
}
}
}

View File

@@ -1,10 +1,13 @@
package org.joinmastodon.android.ui.displayitems;
import static org.joinmastodon.android.GlobalUserPreferences.*;
import android.animation.Animator;
import android.animation.AnimatorListenerAdapter;
import android.animation.AnimatorSet;
import android.animation.ObjectAnimator;
import android.app.Activity;
import android.graphics.Rect;
import android.graphics.drawable.ColorDrawable;
import android.graphics.drawable.Drawable;
import android.graphics.drawable.LayerDrawable;
@@ -13,9 +16,11 @@ import android.util.Pair;
import android.view.Gravity;
import android.view.View;
import android.view.ViewGroup;
import android.view.ViewPropertyAnimator;
import android.view.ViewTreeObserver;
import android.widget.FrameLayout;
import android.widget.ImageButton;
import android.widget.ImageView;
import android.widget.TextView;
import org.joinmastodon.android.R;
@@ -29,6 +34,7 @@ import org.joinmastodon.android.ui.drawables.SpoilerStripesDrawable;
import org.joinmastodon.android.ui.photoviewer.AltTextSheet;
import org.joinmastodon.android.ui.photoviewer.PhotoViewerHost;
import org.joinmastodon.android.ui.utils.MediaAttachmentViewController;
import org.joinmastodon.android.ui.utils.UiUtils;
import org.joinmastodon.android.ui.views.FrameLayoutThatOnlyMeasuresFirstChild;
import org.joinmastodon.android.ui.views.MaxWidthFrameLayout;
import org.joinmastodon.android.ui.views.MediaGridLayout;
@@ -51,13 +57,11 @@ import me.grishka.appkit.utils.V;
public class MediaGridStatusDisplayItem extends StatusDisplayItem{
private static final String TAG="MediaGridDisplayItem";
private final PhotoLayoutHelper.TiledLayoutResult tiledLayout;
private PhotoLayoutHelper.TiledLayoutResult tiledLayout;
private final TypedObjectPool<GridItemType, MediaAttachmentViewController> viewPool;
private final List<Attachment> attachments;
private final Map<String, Pair<String, String>> translatedAttachments = new HashMap<>();
private final ArrayList<ImageLoaderRequest> requests=new ArrayList<>();
public final Status status;
public boolean sensitiveRevealed;
public String sensitiveTitle;
public MediaGridStatusDisplayItem(String parentID, BaseStatusListFragment<?> parentFragment, PhotoLayoutHelper.TiledLayoutResult tiledLayout, List<Attachment> attachments, Status status){
@@ -66,12 +70,11 @@ public class MediaGridStatusDisplayItem extends StatusDisplayItem{
this.viewPool=parentFragment.getAttachmentViewsPool();
this.attachments=attachments;
this.status=status;
sensitiveRevealed=!status.sensitive;
for(Attachment att:attachments){
requests.add(new UrlImageLoaderRequest(switch(att.type){
case IMAGE -> att.url;
case VIDEO, GIFV -> att.previewUrl;
default -> throw new IllegalStateException("Unexpected value: "+att.type);
case VIDEO, GIFV -> att.previewUrl == null ? att.url : att.previewUrl;
default -> throw new IllegalStateException("Unexpected value: "+att.url);
}, 1000, 1000));
}
}
@@ -104,13 +107,22 @@ public class MediaGridStatusDisplayItem extends StatusDisplayItem{
private final ArrayList<MediaAttachmentViewController> controllers=new ArrayList<>();
private final MaxWidthFrameLayout overlays;
private final FrameLayout altTextWrapper;
private final TextView altTextButton;
private final ImageView noAltTextButton;
private final View altTextScroller;
private final ImageButton altTextClose;
private final TextView altText, noAltText;
private final View sensitiveOverlay;
private final LayerDrawable sensitiveOverlayBG;
private static final ColorDrawable drawableForWhenThereIsNoBlurhash=new ColorDrawable(0xffffffff);
private final TextView hideSensitiveButton;
// private final FrameLayout hideSensitiveButton;
private final TextView sensitiveText;
private int altTextIndex=-1;
private Animator altTextAnimator;
public Holder(Activity activity, ViewGroup parent){
super(new FrameLayoutThatOnlyMeasuresFirstChild(activity));
wrapper=(FrameLayout)itemView;
@@ -119,15 +131,23 @@ public class MediaGridStatusDisplayItem extends StatusDisplayItem{
wrapper.setClipToPadding(false);
overlays=new MaxWidthFrameLayout(activity);
overlays.setMaxWidth(V.dp(MediaGridLayout.MAX_WIDTH));
overlays.setMaxWidth(UiUtils.MAX_WIDTH);
wrapper.addView(overlays, new FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT, Gravity.CENTER_HORIZONTAL));
hideSensitiveButton=(TextView) activity.getLayoutInflater().inflate(R.layout.alt_text_badge, overlays, false);
hideSensitiveButton.setText(R.string.hide);
FrameLayout.LayoutParams lp;
overlays.addView(hideSensitiveButton, lp=new FrameLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, V.dp(24), Gravity.END | Gravity.BOTTOM));
int margin=V.dp(8);
lp.setMargins(margin, margin, margin, margin);
activity.getLayoutInflater().inflate(R.layout.overlay_image_alt_text, overlays);
altTextWrapper=findViewById(R.id.alt_text_wrapper);
altTextButton=findViewById(R.id.alt_button);
noAltTextButton=findViewById(R.id.no_alt_button);
altTextScroller=findViewById(R.id.alt_text_scroller);
altTextClose=findViewById(R.id.alt_text_close);
altText=findViewById(R.id.alt_text);
noAltText=findViewById(R.id.no_alt_text);
altTextClose.setOnClickListener(this::onAltTextCloseClick);
// megalodon: no sensitive hide button because the visibility toggle looks prettier imo
// hideSensitiveButton=(FrameLayout) activity.getLayoutInflater().inflate(R.layout.alt_text_badge, overlays, false);
// ((TextView) hideSensitiveButton.findViewById(R.id.alt_button)).setText(R.string.hide);
// overlays.addView(hideSensitiveButton, new FrameLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT, Gravity.END | Gravity.TOP));
activity.getLayoutInflater().inflate(R.layout.overlay_image_sensitive, overlays);
sensitiveOverlay=findViewById(R.id.sensitive_overlay);
@@ -136,14 +156,17 @@ public class MediaGridStatusDisplayItem extends StatusDisplayItem{
sensitiveOverlayBG.setDrawableByLayerId(R.id.right_drawable, new SpoilerStripesDrawable(true));
sensitiveOverlay.setBackground(sensitiveOverlayBG);
sensitiveOverlay.setOnClickListener(v->revealSensitive());
hideSensitiveButton.setOnClickListener(v->hideSensitive());
// hideSensitiveButton.setOnClickListener(v->hideSensitive());
sensitiveText=findViewById(R.id.sensitive_text);
}
@Override
public void onBind(MediaGridStatusDisplayItem item){
wrapper.setPadding(0, 0, 0, item.inset ? 0 : V.dp(8));
wrapper.setPadding(0, 0, 0, 0); // item.inset ? 0 : V.dp(8));
if(altTextAnimator!=null)
altTextAnimator.cancel();
layout.setTiledLayout(item.tiledLayout);
for(MediaAttachmentViewController c:controllers){
@@ -151,7 +174,9 @@ public class MediaGridStatusDisplayItem extends StatusDisplayItem{
}
layout.removeAllViews();
controllers.clear();
int i=0;
if (!item.attachments.isEmpty()) updateBlurhashInSensitiveOverlay();
for(Attachment att:item.attachments){
MediaAttachmentViewController c=item.viewPool.obtain(switch(att.type){
case IMAGE -> GridItemType.PHOTO;
@@ -166,10 +191,10 @@ public class MediaGridStatusDisplayItem extends StatusDisplayItem{
layout.addView(c.view);
c.view.setOnClickListener(clickListener);
c.view.setTag(i);
if(c.altButton!=null){
c.altButton.setOnClickListener(altTextClickListener);
c.altButton.setTag(i);
c.altButton.setAlpha(1f);
if(c.btnsWrap!=null){
c.btnsWrap.setOnClickListener(altTextClickListener);
c.btnsWrap.setTag(i);
c.btnsWrap.setAlpha(1f);
}
controllers.add(c);
@@ -194,24 +219,44 @@ public class MediaGridStatusDisplayItem extends StatusDisplayItem{
c.bind(att, item.status);
i++;
}
altTextButton.setVisibility(View.VISIBLE);
noAltTextButton.setVisibility(View.VISIBLE);
altTextWrapper.setVisibility(View.GONE);
altTextIndex=-1;
if(!item.sensitiveRevealed){
if(!item.status.sensitiveRevealed){
sensitiveOverlay.setVisibility(View.VISIBLE);
layout.setVisibility(View.INVISIBLE);
updateBlurhashInSensitiveOverlay();
}else{
sensitiveOverlay.setVisibility(View.GONE);
layout.setVisibility(View.VISIBLE);
}
hideSensitiveButton.setVisibility(item.status.sensitive ? View.VISIBLE : View.GONE);
// hideSensitiveButton.setVisibility(item.status.sensitive ? View.VISIBLE : View.GONE);
if(!TextUtils.isEmpty(item.sensitiveTitle))
sensitiveText.setText(item.sensitiveTitle);
else if (!item.status.sensitive)
sensitiveText.setText(R.string.media_hidden);
else
sensitiveText.setText(R.string.sensitive_content_explain);
boolean insetAndLast=item.inset && isLastDisplayItemForStatus();
wrapper.setClipToOutline(insetAndLast);
wrapper.setOutlineProvider(insetAndLast ? OutlineProviders.bottomRoundedRect(12) : null);
}
@Override
public void setImage(int index, Drawable drawable){
if(item.attachments.get(index).meta==null){
Rect bounds=drawable.getBounds();
drawable.setBounds(bounds.left, bounds.top, bounds.left+drawable.getIntrinsicWidth(), bounds.top+drawable.getIntrinsicHeight());
Attachment.Metadata metadata = new Attachment.Metadata();
metadata.width=drawable.getIntrinsicWidth();
metadata.height=drawable.getIntrinsicHeight();
item.attachments.get(index).meta=metadata;
item.tiledLayout=PhotoLayoutHelper.processThumbs(item.attachments);
UiUtils.beginLayoutTransition((ViewGroup) itemView);
rebind();
}
controllers.get(index).setImage(drawable);
}
@@ -226,13 +271,145 @@ public class MediaGridStatusDisplayItem extends StatusDisplayItem{
}
private void onAltTextClick(View v){
if(altTextAnimator!=null)
altTextAnimator.cancel();
// V.setVisibilityAnimated(hideSensitiveButton, View.GONE);
V.cancelVisibilityAnimation(altTextWrapper);
v.setVisibility(View.INVISIBLE);
int index=(Integer)v.getTag();
altTextIndex=index;
Attachment att=item.attachments.get(index);
new AltTextSheet(v.getContext(), att).show();
boolean hasAltText = !TextUtils.isEmpty(att.description);
if ((hasAltText && !showAltIndicator) || (!hasAltText && !showNoAltIndicator)) return;
altTextButton.setVisibility(hasAltText && showAltIndicator ? View.VISIBLE : View.GONE);
noAltTextButton.setVisibility(!hasAltText && showNoAltIndicator ? View.VISIBLE : View.GONE);
altText.setVisibility(hasAltText && showAltIndicator ? View.VISIBLE : View.GONE);
noAltText.setVisibility(!hasAltText && showNoAltIndicator ? View.VISIBLE : View.GONE);
altText.setText(att.description);
altTextWrapper.setVisibility(View.VISIBLE);
altTextWrapper.setBackgroundResource(hasAltText ? R.drawable.bg_image_alt_text_overlay : R.drawable.bg_image_no_alt_overlay);
altTextWrapper.getViewTreeObserver().addOnPreDrawListener(new ViewTreeObserver.OnPreDrawListener(){
@Override
public boolean onPreDraw(){
altTextWrapper.getViewTreeObserver().removeOnPreDrawListener(this);
int[] loc={0, 0};
v.getLocationInWindow(loc);
int btnL=loc[0], btnT=loc[1];
overlays.getLocationInWindow(loc);
btnL-=loc[0];
btnT-=loc[1];
ViewGroup.MarginLayoutParams margins = (ViewGroup.MarginLayoutParams) altTextWrapper.getLayoutParams();
ArrayList<Animator> anims=new ArrayList<>();
anims.add(ObjectAnimator.ofFloat(altTextButton, View.ALPHA, 1, 0));
anims.add(ObjectAnimator.ofFloat(noAltTextButton, View.ALPHA, 1, 0));
anims.add(ObjectAnimator.ofFloat(altTextScroller, View.ALPHA, 0, 1));
anims.add(ObjectAnimator.ofFloat(altTextClose, View.ALPHA, 0, 1));
anims.add(ObjectAnimator.ofInt(altTextWrapper, "left", btnL+margins.leftMargin, altTextWrapper.getLeft()));
anims.add(ObjectAnimator.ofInt(altTextWrapper, "top", btnT+margins.topMargin, altTextWrapper.getTop()));
anims.add(ObjectAnimator.ofInt(altTextWrapper, "right", btnL+v.getWidth()-margins.rightMargin, altTextWrapper.getRight()));
anims.add(ObjectAnimator.ofInt(altTextWrapper, "bottom", btnT+v.getHeight()-margins.bottomMargin, altTextWrapper.getBottom()));
for(Animator a:anims)
a.setDuration(300);
for(MediaAttachmentViewController c:controllers){
if(c.btnsWrap!=null && c.btnsWrap!=v){
anims.add(ObjectAnimator.ofFloat(c.btnsWrap, View.ALPHA, 1, 0).setDuration(150));
}
if (c.extraBadge != null) {
anims.add(ObjectAnimator.ofFloat(c.extraBadge, View.ALPHA, 1, 0).setDuration(150));
}
}
AnimatorSet set=new AnimatorSet();
set.playTogether(anims);
set.setInterpolator(CubicBezierInterpolator.DEFAULT);
set.addListener(new AnimatorListenerAdapter(){
@Override
public void onAnimationEnd(Animator animation){
altTextAnimator=null;
for(MediaAttachmentViewController c:controllers){
if(c.btnsWrap!=null){
c.btnsWrap.setVisibility(View.INVISIBLE);
}
if (c.extraBadge != null) c.extraBadge.setVisibility(View.INVISIBLE);
}
}
});
altTextAnimator=set;
set.start();
return true;
}
});
}
private void onAltTextCloseClick(View v){
if(altTextAnimator!=null)
altTextAnimator.cancel();
// V.setVisibilityAnimated(hideSensitiveButton, item.status.sensitive ? View.VISIBLE : View.GONE);
V.cancelVisibilityAnimation(altTextWrapper);
View btn=controllers.get(altTextIndex).btnsWrap;
int i=0;
for(MediaAttachmentViewController c:controllers){
boolean hasAltText = !TextUtils.isEmpty(item.attachments.get(i).description);
if(c.btnsWrap!=null
&& c.btnsWrap!=btn
&& ((hasAltText && showAltIndicator) || (!hasAltText && showNoAltIndicator))
) c.btnsWrap.setVisibility(View.VISIBLE);
if (c.extraBadge != null) c.extraBadge.setVisibility(View.VISIBLE);
i++;
}
int[] loc={0, 0};
btn.getLocationInWindow(loc);
int btnL=loc[0], btnT=loc[1];
overlays.getLocationInWindow(loc);
btnL-=loc[0];
btnT-=loc[1];
ViewGroup.MarginLayoutParams margins = (ViewGroup.MarginLayoutParams) altTextWrapper.getLayoutParams();
ArrayList<Animator> anims=new ArrayList<>();
anims.add(ObjectAnimator.ofFloat(altTextButton, View.ALPHA, 1));
anims.add(ObjectAnimator.ofFloat(noAltTextButton, View.ALPHA, 1));
anims.add(ObjectAnimator.ofFloat(altTextScroller, View.ALPHA, 0));
anims.add(ObjectAnimator.ofFloat(altTextClose, View.ALPHA, 0));
anims.add(ObjectAnimator.ofInt(altTextWrapper, "left", btnL+margins.leftMargin));
anims.add(ObjectAnimator.ofInt(altTextWrapper, "top", btnT+margins.topMargin));
anims.add(ObjectAnimator.ofInt(altTextWrapper, "right", btnL+btn.getWidth()-margins.rightMargin));
anims.add(ObjectAnimator.ofInt(altTextWrapper, "bottom", btnT+btn.getHeight()-margins.bottomMargin));
for(Animator a:anims)
a.setDuration(300);
for(MediaAttachmentViewController c:controllers){
if(c.btnsWrap!=null && c.btnsWrap!=btn){
anims.add(ObjectAnimator.ofFloat(c.btnsWrap, View.ALPHA, 1).setDuration(150));
}
if (c.extraBadge != null) {
anims.add(ObjectAnimator.ofFloat(c.extraBadge, View.ALPHA, 1).setDuration(150));
}
}
AnimatorSet set=new AnimatorSet();
set.playTogether(anims);
set.setInterpolator(CubicBezierInterpolator.DEFAULT);
set.addListener(new AnimatorListenerAdapter(){
@Override
public void onAnimationEnd(Animator animation){
altTextAnimator=null;
V.setVisibilityAnimated(altTextWrapper, View.GONE);
V.setVisibilityAnimated(btn, View.VISIBLE);
btn.setAlpha(1);
}
});
altTextAnimator=set;
set.start();
}
public MediaAttachmentViewController getViewController(int index){
return controllers.get(index);
return index<controllers.size() ? controllers.get(index) : null;
}
public void setClipChildren(boolean clip){
@@ -241,23 +418,25 @@ public class MediaGridStatusDisplayItem extends StatusDisplayItem{
}
private void updateBlurhashInSensitiveOverlay(){
Drawable d=item.attachments.get(0).blurhashPlaceholder;
Drawable d = item.attachments.get(0).blurhashPlaceholder;
sensitiveOverlayBG.setDrawableByLayerId(R.id.blurhash, d==null ? drawableForWhenThereIsNoBlurhash : d.mutate());
sensitiveOverlay.setBackground(sensitiveOverlayBG);
}
private void revealSensitive(){
if(item.sensitiveRevealed)
public void revealSensitive(){
if(item.status.sensitiveRevealed)
return;
item.sensitiveRevealed=true;
item.status.sensitiveRevealed=true;
V.setVisibilityAnimated(sensitiveOverlay, View.GONE);
layout.setVisibility(View.VISIBLE);
item.parentFragment.onSensitiveRevealed(this);
}
private void hideSensitive(){
if(!item.sensitiveRevealed)
public void hideSensitive(){
if(!item.status.sensitiveRevealed)
return;
updateBlurhashInSensitiveOverlay();
item.sensitiveRevealed=false;
item.status.sensitiveRevealed=false;
V.setVisibilityAnimated(sensitiveOverlay, View.VISIBLE, ()->layout.setVisibility(View.INVISIBLE));
}

View File

@@ -1,12 +1,17 @@
package org.joinmastodon.android.ui.displayitems;
import static org.joinmastodon.android.MastodonApp.context;
import static org.joinmastodon.android.ui.utils.UiUtils.generateFormattedString;
import android.annotation.SuppressLint;
import android.app.Activity;
import android.content.res.ColorStateList;
import android.graphics.drawable.Animatable;
import android.graphics.drawable.Drawable;
import android.os.Bundle;
import android.text.SpannableStringBuilder;
import android.text.TextUtils;
import android.text.style.TypefaceSpan;
import android.util.TypedValue;
import android.view.View;
import android.view.ViewGroup;
import android.widget.ImageView;
@@ -14,8 +19,12 @@ import android.widget.TextView;
import org.joinmastodon.android.GlobalUserPreferences;
import org.joinmastodon.android.R;
import org.joinmastodon.android.api.session.AccountSession;
import org.joinmastodon.android.api.session.AccountSessionManager;
import org.joinmastodon.android.fragments.BaseStatusListFragment;
import org.joinmastodon.android.fragments.NotificationsListFragment;
import org.joinmastodon.android.fragments.ProfileFragment;
import org.joinmastodon.android.model.Emoji;
import org.joinmastodon.android.model.Notification;
import org.joinmastodon.android.ui.OutlineProviders;
import org.joinmastodon.android.ui.text.HtmlParser;
@@ -23,6 +32,8 @@ import org.joinmastodon.android.ui.utils.CustomEmojiHelper;
import org.joinmastodon.android.ui.utils.UiUtils;
import org.parceler.Parcels;
import java.util.Collections;
import me.grishka.appkit.Nav;
import me.grishka.appkit.imageloader.ImageLoaderViewHolder;
import me.grishka.appkit.imageloader.requests.ImageLoaderRequest;
@@ -32,41 +43,54 @@ import me.grishka.appkit.utils.V;
public class NotificationHeaderStatusDisplayItem extends StatusDisplayItem{
public final Notification notification;
private ImageLoaderRequest avaRequest;
private String accountID;
private CustomEmojiHelper emojiHelper=new CustomEmojiHelper();
private CharSequence text;
private final String accountID;
private final CustomEmojiHelper emojiHelper=new CustomEmojiHelper();
private final CharSequence text;
private final CharSequence timestamp;
public NotificationHeaderStatusDisplayItem(String parentID, BaseStatusListFragment parentFragment, Notification notification, String accountID){
super(parentID, parentFragment);
this.notification=notification;
this.accountID=accountID;
this.timestamp=notification.createdAt==null ? null : UiUtils.formatRelativeTimestamp(context, notification.createdAt);
if(notification.type==Notification.Type.POLL){
text=parentFragment.getString(R.string.poll_ended);
}else{
avaRequest=new UrlImageLoaderRequest(GlobalUserPreferences.playGifs ? notification.account.avatar : notification.account.avatarStatic, V.dp(50), V.dp(50));
SpannableStringBuilder parsedName=new SpannableStringBuilder(notification.account.displayName);
AccountSession session = AccountSessionManager.get(accountID);
avaRequest=new UrlImageLoaderRequest(
TextUtils.isEmpty(notification.account.avatar) ? session.getDefaultAvatarUrl() :
GlobalUserPreferences.playGifs ? notification.account.avatar : notification.account.avatarStatic,
V.dp(50), V.dp(50));
SpannableStringBuilder parsedName=new SpannableStringBuilder(notification.account.getDisplayName());
HtmlParser.parseCustomEmoji(parsedName, notification.account.emojis);
emojiHelper.setText(parsedName);
String[] parts=parentFragment.getString(switch(notification.type){
String str = parentFragment.getString(switch(notification.type){
case FOLLOW -> R.string.user_followed_you;
case FOLLOW_REQUEST -> R.string.user_sent_follow_request;
case REBLOG -> R.string.notification_boosted;
case FAVORITE -> R.string.user_favorited;
case POLL -> R.string.poll_ended;
case UPDATE -> R.string.sk_post_edited;
case SIGN_UP -> R.string.sk_signed_up;
case REPORT -> R.string.sk_reported;
case REACTION, PLEROMA_EMOJI_REACTION ->
!TextUtils.isEmpty(notification.emoji) ? R.string.sk_reacted_with : R.string.sk_reacted;
default -> throw new IllegalStateException("Unexpected value: "+notification.type);
}).split("%s", 2);
SpannableStringBuilder text=new SpannableStringBuilder();
if(parts.length>1 && !TextUtils.isEmpty(parts[0]))
text.append(parts[0]);
text.append(parsedName, new TypefaceSpan("sans-serif-medium"), 0);
if(parts.length==1){
text.append(' ');
text.append(parts[0]);
}else if(!TextUtils.isEmpty(parts[1])){
text.append(parts[1]);
});
if (!TextUtils.isEmpty(notification.emoji)) {
SpannableStringBuilder emoji = new SpannableStringBuilder(notification.emoji);
if (!TextUtils.isEmpty(notification.emojiUrl)) {
HtmlParser.parseCustomEmoji(emoji, Collections.singletonList(new Emoji(
notification.emoji, notification.emojiUrl, notification.emojiUrl
)));
}
this.text = generateFormattedString(str, parsedName, emoji);
} else {
this.text = generateFormattedString(str, parsedName);
}
this.text=text;
emojiHelper.setText(text);
}
}
@@ -89,18 +113,26 @@ public class NotificationHeaderStatusDisplayItem extends StatusDisplayItem{
}
public static class Holder extends StatusDisplayItem.Holder<NotificationHeaderStatusDisplayItem> implements ImageLoaderViewHolder{
private final ImageView icon, avatar;
private final TextView text;
private final ImageView icon, avatar, deleteNotification;
private final TextView text, timestamp;
public Holder(Activity activity, ViewGroup parent){
super(activity, R.layout.display_item_notification_header, parent);
icon=findViewById(R.id.icon);
avatar=findViewById(R.id.avatar);
text=findViewById(R.id.text);
timestamp=findViewById(R.id.timestamp);
deleteNotification=findViewById(R.id.delete_notification);
avatar.setOutlineProvider(OutlineProviders.roundedRect(8));
avatar.setClipToOutline(true);
avatar.setOnClickListener(this::onAvaClick);
deleteNotification.setOnClickListener(v->UiUtils.confirmDeleteNotification(activity, item.parentFragment.getAccountID(), item.notification, ()->{
if (item.parentFragment instanceof NotificationsListFragment fragment) {
fragment.removeNotification(item.notification);
}
}));
itemView.setOnClickListener(this::onItemClick);
}
@Override
@@ -111,6 +143,8 @@ public class NotificationHeaderStatusDisplayItem extends StatusDisplayItem{
item.emojiHelper.setImageDrawable(index-1, image);
text.invalidate();
}
if(image instanceof Animatable)
((Animatable) image).start();
}
@Override
@@ -121,28 +155,38 @@ public class NotificationHeaderStatusDisplayItem extends StatusDisplayItem{
ImageLoaderViewHolder.super.clearImage(index);
}
@SuppressLint("ResourceType")
@Override
public void onBind(NotificationHeaderStatusDisplayItem item){
text.setText(item.text);
timestamp.setText(item.timestamp);
avatar.setVisibility(item.notification.type==Notification.Type.POLL ? View.GONE : View.VISIBLE);
// TODO use real icons
icon.setImageResource(switch(item.notification.type){
case FAVORITE -> R.drawable.ic_star_fill1_24px;
case REBLOG -> R.drawable.ic_repeat_fill1_24px;
case FOLLOW, FOLLOW_REQUEST -> R.drawable.ic_person_add_fill1_24px;
case POLL -> R.drawable.ic_insert_chart_fill1_24px;
case FAVORITE -> GlobalUserPreferences.likeIcon ? R.drawable.ic_fluent_heart_24_filled : R.drawable.ic_fluent_star_24_filled;
case REBLOG -> R.drawable.ic_fluent_arrow_repeat_all_24_filled;
case FOLLOW, FOLLOW_REQUEST -> R.drawable.ic_fluent_person_add_24_filled;
case POLL -> R.drawable.ic_fluent_poll_24_filled;
case REPORT -> R.drawable.ic_fluent_warning_24_filled;
case SIGN_UP -> R.drawable.ic_fluent_person_available_24_filled;
case UPDATE -> R.drawable.ic_fluent_edit_24_filled;
case REACTION, PLEROMA_EMOJI_REACTION -> R.drawable.ic_fluent_add_24_filled;
default -> throw new IllegalStateException("Unexpected value: "+item.notification.type);
});
icon.setImageTintList(ColorStateList.valueOf(UiUtils.getThemeColor(item.parentFragment.getActivity(), switch(item.notification.type){
case FAVORITE -> R.attr.colorFavorite;
case FAVORITE -> GlobalUserPreferences.likeIcon ? R.attr.colorLike : R.attr.colorFavorite;
case REBLOG -> R.attr.colorBoost;
case FOLLOW, FOLLOW_REQUEST -> R.attr.colorFollow;
case POLL -> R.attr.colorPoll;
default -> throw new IllegalStateException("Unexpected value: "+item.notification.type);
default -> android.R.attr.colorAccent;
})));
deleteNotification.setVisibility(GlobalUserPreferences.enableDeleteNotifications && item.notification != null ? View.VISIBLE : View.GONE);
itemView.setBackgroundResource(0);
}
private void onAvaClick(View v){
public void onItemClick(View v) {
if (item.notification.type == Notification.Type.REPORT) {
UiUtils.showFragmentForNotification(item.parentFragment.getContext(), item.notification, item.accountID, null);
return;
}
Bundle args=new Bundle();
args.putString("account", item.accountID);
args.putParcelable("profileAccount", Parcels.wrap(item.notification.account));

View File

@@ -9,14 +9,18 @@ import android.widget.TextView;
import org.joinmastodon.android.R;
import org.joinmastodon.android.fragments.BaseStatusListFragment;
import org.joinmastodon.android.model.Poll;
import org.joinmastodon.android.model.Status;
import org.joinmastodon.android.ui.utils.UiUtils;
public class PollFooterStatusDisplayItem extends StatusDisplayItem{
public final Poll poll;
public boolean resultsVisible=false;
public final Status status;
public PollFooterStatusDisplayItem(String parentID, BaseStatusListFragment parentFragment, Poll poll){
public PollFooterStatusDisplayItem(String parentID, BaseStatusListFragment parentFragment, Poll poll, Status status){
super(parentID, parentFragment);
this.poll=poll;
this.status=status;
}
@Override
@@ -26,28 +30,40 @@ public class PollFooterStatusDisplayItem extends StatusDisplayItem{
public static class Holder extends StatusDisplayItem.Holder<PollFooterStatusDisplayItem>{
private TextView text;
private Button button;
private Button voteButton, resultsButton;
private ViewGroup wrapper;
public Holder(Activity activity, ViewGroup parent){
super(activity, R.layout.display_item_poll_footer, parent);
text=findViewById(R.id.text);
button=findViewById(R.id.vote_btn);
button.setOnClickListener(v->item.parentFragment.onPollVoteButtonClick(this));
voteButton=findViewById(R.id.vote_btn);
voteButton.setOnClickListener(v->item.parentFragment.onPollVoteButtonClick(this));
resultsButton=findViewById(R.id.results_btn);
wrapper=findViewById(R.id.wrapper);
resultsButton.setOnClickListener(v-> {
item.resultsVisible = !item.resultsVisible;
item.parentFragment.onPollViewResultsButtonClick(this, item.resultsVisible);
rebind();
UiUtils.beginLayoutTransition(wrapper);
});
}
@Override
public void onBind(PollFooterStatusDisplayItem item){
String text=item.parentFragment.getResources().getQuantityString(R.plurals.x_votes, item.poll.votesCount, item.poll.votesCount);
if(item.poll.expiresAt!=null && !item.poll.isExpired()){
text+=" · "+UiUtils.formatTimeLeft(itemView.getContext(), item.poll.expiresAt);
if(item.poll.multiple)
text+=" · "+item.parentFragment.getString(R.string.poll_multiple_choice);
}else if(item.poll.isExpired()){
text+=" · "+item.parentFragment.getString(R.string.poll_closed);
}
String sep=" "+item.parentFragment.getString(R.string.sk_separator)+" ";
if(item.poll.expiresAt!=null && !item.poll.isExpired())
text+=sep+UiUtils.formatTimeLeft(itemView.getContext(), item.poll.expiresAt).replaceAll(" ", " ");
else if(item.poll.isExpired())
text+=sep+item.parentFragment.getString(R.string.poll_closed).replaceAll(" ", " ");
if(item.poll.multiple)
text+=sep+item.parentFragment.getString(R.string.sk_poll_multiple_choice).replaceAll(" ", " ");
this.text.setText(text);
button.setVisibility(item.poll.isExpired() || item.poll.voted || !item.poll.multiple ? View.GONE : View.VISIBLE);
button.setEnabled(item.poll.selectedOptions!=null && !item.poll.selectedOptions.isEmpty());
resultsButton.setVisibility(item.poll.isExpired() || item.poll.voted ? View.GONE : View.VISIBLE);
resultsButton.setText(item.resultsVisible ? R.string.sk_poll_hide_results : R.string.sk_poll_show_results);
resultsButton.setSelected(item.resultsVisible);
voteButton.setVisibility(item.poll.isExpired() || item.poll.voted ? View.GONE : View.VISIBLE);
voteButton.setEnabled(item.poll.selectedOptions!=null && !item.poll.selectedOptions.isEmpty() && !item.resultsVisible);
}
}
}

View File

@@ -5,6 +5,7 @@ import android.graphics.drawable.Animatable;
import android.graphics.drawable.Drawable;
import android.view.View;
import android.view.ViewGroup;
import android.widget.ImageView;
import android.widget.TextView;
import org.joinmastodon.android.R;
@@ -12,10 +13,12 @@ import org.joinmastodon.android.fragments.BaseStatusListFragment;
import org.joinmastodon.android.model.Poll;
import org.joinmastodon.android.model.Status;
import org.joinmastodon.android.ui.OutlineProviders;
import org.joinmastodon.android.model.Status;
import org.joinmastodon.android.ui.text.HtmlParser;
import org.joinmastodon.android.ui.utils.CustomEmojiHelper;
import org.joinmastodon.android.ui.utils.UiUtils;
import java.util.Collections;
import java.util.Locale;
import me.grishka.appkit.imageloader.ImageLoaderViewHolder;
@@ -43,6 +46,10 @@ public class PollOptionStatusDisplayItem extends StatusDisplayItem{
text=HtmlParser.parseCustomEmoji(option.title, poll.emojis);
emojiHelper.setText(text);
showResults=poll.isExpired() || poll.voted;
calculateResults();
}
private void calculateResults() {
int total=poll.votersCount>0 ? poll.votersCount : poll.votesCount;
if(showResults && option.votesCount!=null && total>0){
votesFraction=(float)option.votesCount/(float)total;
@@ -70,17 +77,17 @@ public class PollOptionStatusDisplayItem extends StatusDisplayItem{
public static class Holder extends StatusDisplayItem.Holder<PollOptionStatusDisplayItem> implements ImageLoaderViewHolder{
private final TextView text, percent;
private final View check, button;
private final Drawable progressBg, progressBgInset;
private final View button;
private final ImageView icon;
private final Drawable progressBg;
public Holder(Activity activity, ViewGroup parent){
super(activity, R.layout.display_item_poll_option, parent);
text=findViewById(R.id.text);
percent=findViewById(R.id.percent);
check=findViewById(R.id.checkbox);
icon=findViewById(R.id.icon);
button=findViewById(R.id.button);
progressBg=activity.getResources().getDrawable(R.drawable.bg_poll_option_voted, activity.getTheme()).mutate();
progressBgInset=activity.getResources().getDrawable(R.drawable.bg_poll_option_voted_inset, activity.getTheme()).mutate();
itemView.setOnClickListener(this::onButtonClick);
button.setOutlineProvider(OutlineProviders.roundedRect(20));
button.setClipToOutline(true);
@@ -98,24 +105,22 @@ public class PollOptionStatusDisplayItem extends StatusDisplayItem{
}
percent.setVisibility(item.showResults ? View.VISIBLE : View.GONE);
itemView.setClickable(!item.showResults);
icon.setImageDrawable(itemView.getContext().getDrawable(item.poll.multiple ?
item.showResults ? R.drawable.ic_poll_checkbox_regular_selector : R.drawable.ic_poll_checkbox_filled_selector :
item.showResults ? R.drawable.ic_poll_option_button : R.drawable.ic_fluent_radio_button_24_selector
));
if(item.showResults){
Drawable bg=item.inset ? progressBgInset : progressBg;
Drawable bg=progressBg;
bg.setLevel(Math.round(10000f*item.votesFraction));
button.setBackground(bg);
itemView.setSelected(item.isMostVoted);
check.setSelected(item.poll.ownVotes!=null && item.poll.ownVotes.contains(item.optionIndex));
itemView.setSelected(item.poll.ownVotes!=null && item.poll.ownVotes.contains(item.optionIndex));
percent.setText(String.format(Locale.getDefault(), "%d%%", Math.round(item.votesFraction*100f)));
}else{
itemView.setSelected(item.poll.selectedOptions!=null && item.poll.selectedOptions.contains(item.option));
button.setBackgroundResource(item.inset ? R.drawable.bg_poll_option_clickable_inset : R.drawable.bg_poll_option_clickable);
}
if(item.inset){
text.setTextColor(itemView.getContext().getColorStateList(R.color.poll_option_text_inset));
percent.setTextColor(itemView.getContext().getColorStateList(R.color.poll_option_text_inset));
}else{
text.setTextColor(UiUtils.getThemeColor(itemView.getContext(), R.attr.colorM3Primary));
percent.setTextColor(UiUtils.getThemeColor(itemView.getContext(), R.attr.colorM3OnSecondaryContainer));
button.setBackgroundResource(R.drawable.bg_poll_option_clickable);
}
text.setTextColor(UiUtils.getThemeColor(itemView.getContext(), android.R.attr.textColorPrimary));
percent.setTextColor(UiUtils.getThemeColor(itemView.getContext(), R.attr.colorM3OnSecondaryContainer));
}
@Override
@@ -136,5 +141,11 @@ public class PollOptionStatusDisplayItem extends StatusDisplayItem{
private void onButtonClick(View v){
item.parentFragment.onPollOptionClick(this);
}
public void showResults(boolean shown) {
item.showResults = shown;
item.calculateResults();
rebind();
}
}
}

View File

@@ -0,0 +1,173 @@
package org.joinmastodon.android.ui.displayitems;
import android.app.Activity;
import android.util.Pair;
import android.view.View;
import android.view.ViewGroup;
import android.widget.FrameLayout;
import android.widget.LinearLayout;
import org.joinmastodon.android.fragments.BaseStatusListFragment;
import org.joinmastodon.android.model.Attachment;
import org.joinmastodon.android.model.Status;
import org.joinmastodon.android.model.Translation;
import org.joinmastodon.android.ui.OutlineProviders;
import org.joinmastodon.android.ui.PhotoLayoutHelper;
import org.joinmastodon.android.ui.utils.PreviewlessMediaAttachmentViewController;
import org.joinmastodon.android.ui.views.FrameLayoutThatOnlyMeasuresFirstChild;
import org.joinmastodon.android.utils.TypedObjectPool;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import me.grishka.appkit.imageloader.requests.ImageLoaderRequest;
import me.grishka.appkit.utils.V;
public class PreviewlessMediaGridStatusDisplayItem extends StatusDisplayItem{
private static final String TAG="PreviewlessMediaGridDisplayItem";
private PhotoLayoutHelper.TiledLayoutResult tiledLayout;
private final TypedObjectPool<MediaGridStatusDisplayItem.GridItemType, PreviewlessMediaAttachmentViewController> viewPool;
private final List<Attachment> attachments;
private final Map<String, Pair<String, String>> translatedAttachments = new HashMap<>();
private final ArrayList<ImageLoaderRequest> requests=new ArrayList<>();
public final Status status;
public String sensitiveTitle;
public PreviewlessMediaGridStatusDisplayItem(String parentID, BaseStatusListFragment<?> parentFragment, PhotoLayoutHelper.TiledLayoutResult tiledLayout, List<Attachment> attachments, Status status){
super(parentID, parentFragment);
this.tiledLayout=tiledLayout;
this.viewPool=parentFragment.getPreviewlessAttachmentViewsPool();
this.attachments=attachments;
this.status=status;
// for(Attachment att:attachments){
// requests.add(new UrlImageLoaderRequest(switch(att.type){
// case IMAGE -> att.url;
// case VIDEO, GIFV -> att.previewUrl == null ? att.url : att.previewUrl;
// default -> throw new IllegalStateException("Unexpected value: "+att.url);
// }, 1000, 1000));
// }
}
@Override
public Type getType(){
return Type.PREVIEWLESS_MEDIA_GRID;
}
@Override
public int getImageCount(){
return attachments.size();
}
@Override
public ImageLoaderRequest getImageRequest(int index){
return requests.get(index);
}
public static class Holder extends StatusDisplayItem.Holder<PreviewlessMediaGridStatusDisplayItem> {
private final FrameLayout wrapper;
private final LinearLayout layout;
private final View.OnClickListener clickListener=this::onViewClick;
private final ArrayList<PreviewlessMediaAttachmentViewController> controllers=new ArrayList<>();
// private final FrameLayout hideSensitiveButton;
public Holder(Activity activity, ViewGroup parent){
super(new FrameLayoutThatOnlyMeasuresFirstChild(activity));
wrapper=(FrameLayout)itemView;
layout= new LinearLayout(activity);
layout.setOrientation(LinearLayout.VERTICAL);
LinearLayout.LayoutParams params = new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT);
layout.setLayoutParams(params);
wrapper.addView(layout);
wrapper.setClipToPadding(false);
// megalodon: no sensitive hide button because the visibility toggle looks prettier imo
// hideSensitiveButton=(FrameLayout) activity.getLayoutInflater().inflate(R.layout.alt_text_badge, overlays, false);
// ((TextView) hideSensitiveButton.findViewById(R.id.alt_button)).setText(R.string.hide);
// overlays.addView(hideSensitiveButton, new FrameLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT, Gravity.END | Gravity.TOP));
// hideSensitiveButton.setOnClickListener(v->hideSensitive());
}
@Override
public void onBind(PreviewlessMediaGridStatusDisplayItem item){
wrapper.setPadding(0, 0, 0, 0); // item.inset ? 0 : V.dp(8));
// if(altTextAnimator!=null)
// altTextAnimator.cancel();
for(PreviewlessMediaAttachmentViewController c:controllers){
item.viewPool.reuse(c.type, c);
}
layout.removeAllViews();
controllers.clear();
int i=0;
// if (!item.attachments.isEmpty()) updateBlurhashInSensitiveOverlay();
for(Attachment att:item.attachments){
PreviewlessMediaAttachmentViewController c=item.viewPool.obtain(switch(att.type){
case IMAGE -> MediaGridStatusDisplayItem.GridItemType.PHOTO;
case VIDEO -> MediaGridStatusDisplayItem.GridItemType.VIDEO;
case GIFV -> MediaGridStatusDisplayItem.GridItemType.GIFV;
default -> throw new IllegalStateException("Unexpected value: "+att.type);
});
if(c.view.getLayoutParams()==null)
c.view.setLayoutParams(new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT));
layout.addView(c.view);
c.view.setOnClickListener(clickListener);
c.view.setTag(i);
controllers.add(c);
if (item.status.translation != null){
if(item.status.translationState==Status.TranslationState.SHOWN){
if(!item.translatedAttachments.containsKey(att.id)){
Optional<Translation.MediaAttachment> translatedAttachment=Arrays.stream(item.status.translation.mediaAttachments).filter(mediaAttachment->mediaAttachment.id.equals(att.id)).findFirst();
translatedAttachment.ifPresent(mediaAttachment->{
item.translatedAttachments.put(mediaAttachment.id, new Pair<>(att.description, mediaAttachment.description));
att.description=mediaAttachment.description;
});
}else{
//SAFETY: must be non-null, as we check if the map contains the attachment before
att.description=Objects.requireNonNull(item.translatedAttachments.get(att.id)).second;
}
}else{
if (item.translatedAttachments.containsKey(att.id)) {
att.description=Objects.requireNonNull(item.translatedAttachments.get(att.id)).first;
}
}
}
c.bind(att, item.status);
i++;
}
boolean insetAndLast=item.inset && isLastDisplayItemForStatus();
wrapper.setClipToOutline(insetAndLast);
wrapper.setOutlineProvider(insetAndLast ? OutlineProviders.bottomRoundedRect(12) : null);
}
private void onViewClick(View v){
int index=(Integer)v.getTag();
item.parentFragment.openPreviewlessMediaPhotoViewer(item.parentID, item.status, index, this);
}
public PreviewlessMediaAttachmentViewController getViewController(int index){
return controllers.get(index);
}
public void setClipChildren(boolean clip){
layout.setClipChildren(clip);
wrapper.setClipChildren(clip);
}
public LinearLayout getLayout(){
return layout;
}
}
}

View File

@@ -1,9 +1,14 @@
package org.joinmastodon.android.ui.displayitems;
import static org.joinmastodon.android.MastodonApp.context;
import android.app.Activity;
import android.content.Context;
import android.graphics.drawable.Drawable;
import android.os.Build;
import android.text.SpannableStringBuilder;
import android.util.TypedValue;
import android.view.View;
import android.view.ViewGroup;
import android.widget.TextView;
@@ -11,6 +16,8 @@ import org.joinmastodon.android.R;
import org.joinmastodon.android.api.session.AccountSessionManager;
import org.joinmastodon.android.fragments.BaseStatusListFragment;
import org.joinmastodon.android.model.Emoji;
import org.joinmastodon.android.model.Status;
import org.joinmastodon.android.model.StatusPrivacy;
import org.joinmastodon.android.ui.text.HtmlParser;
import org.joinmastodon.android.ui.utils.CustomEmojiHelper;
import org.joinmastodon.android.ui.utils.UiUtils;
@@ -18,23 +25,53 @@ import org.joinmastodon.android.ui.utils.UiUtils;
import java.util.List;
import androidx.annotation.DrawableRes;
import androidx.annotation.Nullable;
import me.grishka.appkit.imageloader.ImageLoaderViewHolder;
import me.grishka.appkit.imageloader.requests.ImageLoaderRequest;
import me.grishka.appkit.utils.V;
public class ReblogOrReplyLineStatusDisplayItem extends StatusDisplayItem{
private CharSequence text;
@DrawableRes
private int icon;
private StatusPrivacy visibility;
@DrawableRes
private int iconEnd;
private CustomEmojiHelper emojiHelper=new CustomEmojiHelper();
private View.OnClickListener handleClick;
public boolean needBottomPadding;
ReblogOrReplyLineStatusDisplayItem extra;
CharSequence fullText;
public ReblogOrReplyLineStatusDisplayItem(String parentID, BaseStatusListFragment parentFragment, CharSequence text, List<Emoji> emojis, @DrawableRes int icon){
public ReblogOrReplyLineStatusDisplayItem(String parentID, BaseStatusListFragment parentFragment, CharSequence text, List<Emoji> emojis, @DrawableRes int icon, StatusPrivacy visibility, @Nullable View.OnClickListener handleClick, Status status) {
this(parentID, parentFragment, text, emojis, icon, visibility, handleClick, text, status);
}
public ReblogOrReplyLineStatusDisplayItem(String parentID, BaseStatusListFragment parentFragment, CharSequence text, List<Emoji> emojis, @DrawableRes int icon, StatusPrivacy visibility, @Nullable View.OnClickListener handleClick, CharSequence fullText, Status status) {
super(parentID, parentFragment);
SpannableStringBuilder ssb=new SpannableStringBuilder(text);
if(AccountSessionManager.get(parentFragment.getAccountID()).getLocalPreferences().customEmojiInNames)
HtmlParser.parseCustomEmoji(ssb, emojis);
this.text=ssb;
emojiHelper.setText(ssb);
this.fullText=fullText;
this.icon=icon;
this.status=status;
this.handleClick=handleClick;
TypedValue outValue = new TypedValue();
context.getTheme().resolveAttribute(android.R.attr.selectableItemBackground, outValue, true);
updateVisibility(visibility);
}
public void updateVisibility(StatusPrivacy visibility) {
this.visibility = visibility;
this.iconEnd = visibility != null ? switch (visibility) {
case PUBLIC -> R.drawable.ic_fluent_earth_20sp_regular;
case UNLISTED -> R.drawable.ic_fluent_lock_open_20sp_regular;
case PRIVATE -> R.drawable.ic_fluent_lock_closed_20sp_filled;
default -> 0;
} : 0;
}
@Override
@@ -44,33 +81,66 @@ public class ReblogOrReplyLineStatusDisplayItem extends StatusDisplayItem{
@Override
public int getImageCount(){
return emojiHelper.getImageCount();
return emojiHelper.getImageCount() + (extra!=null ? extra.emojiHelper.getImageCount() : 0);
}
@Override
public ImageLoaderRequest getImageRequest(int index){
return emojiHelper.getImageRequest(index);
int firstHelperCount=emojiHelper.getImageCount();
CustomEmojiHelper helper=index<firstHelperCount ? emojiHelper : extra.emojiHelper;
return helper.getImageRequest(firstHelperCount>0 ? index%firstHelperCount : index);
}
public static class Holder extends StatusDisplayItem.Holder<ReblogOrReplyLineStatusDisplayItem> implements ImageLoaderViewHolder{
private final TextView text;
private final TextView text, extraText;
private final View separator;
public Holder(Activity activity, ViewGroup parent){
super(activity, R.layout.display_item_reblog_or_reply_line, parent);
text=findViewById(R.id.text);
extraText=findViewById(R.id.extra_text);
separator=findViewById(R.id.separator);
}
private void bindLine(ReblogOrReplyLineStatusDisplayItem item, TextView text) {
text.setText(item.text);
text.setCompoundDrawablesRelativeWithIntrinsicBounds(item.icon, 0, item.iconEnd, 0);
text.setOnClickListener(item.handleClick);
text.setEnabled(!item.inset && item.handleClick != null);
text.setClickable(!item.inset && item.handleClick != null);
Context ctx = itemView.getContext();
int visibilityText = item.visibility != null ? switch (item.visibility) {
case PUBLIC -> R.string.visibility_public;
case UNLISTED -> R.string.sk_visibility_unlisted;
case PRIVATE -> R.string.visibility_followers_only;
case LOCAL -> R.string.sk_local_only;
default -> 0;
} : 0;
String visibilityDescription=visibilityText!=0 ? " (" + ctx.getString(visibilityText) + ")" : null;
text.setContentDescription(item.fullText==null && visibilityDescription==null ? null :
(item.fullText!=null ? item.fullText : item.text)
+ (visibilityDescription!=null ? visibilityDescription : ""));
if(Build.VERSION.SDK_INT<Build.VERSION_CODES.N)
UiUtils.fixCompoundDrawableTintOnAndroid6(text);
text.setCompoundDrawableTintList(text.getTextColors());
}
@Override
public void onBind(ReblogOrReplyLineStatusDisplayItem item){
text.setText(item.text);
text.setCompoundDrawablesRelativeWithIntrinsicBounds(item.icon, 0, 0, 0);
if(Build.VERSION.SDK_INT<Build.VERSION_CODES.N)
UiUtils.fixCompoundDrawableTintOnAndroid6(text);
bindLine(item, text);
if (item.extra != null) bindLine(item.extra, extraText);
extraText.setVisibility(item.extra == null ? View.GONE : View.VISIBLE);
separator.setVisibility(item.extra == null ? View.GONE : View.VISIBLE);
itemView.setPadding(itemView.getPaddingLeft(), itemView.getPaddingTop(), itemView.getPaddingRight(), item.needBottomPadding ? V.dp(16) : 0);
}
@Override
public void setImage(int index, Drawable image){
item.emojiHelper.setImageDrawable(index, image);
int firstHelperCount=item.emojiHelper.getImageCount();
CustomEmojiHelper helper=index<firstHelperCount ? item.emojiHelper : item.extra.emojiHelper;
helper.setImageDrawable(firstHelperCount>0 ? index%firstHelperCount : index, image);
text.invalidate();
extraText.invalidate();
}
@Override

View File

@@ -6,6 +6,7 @@ import android.graphics.drawable.LayerDrawable;
import android.text.TextUtils;
import android.view.View;
import android.view.ViewGroup;
import android.widget.ImageView;
import android.widget.TextView;
import org.joinmastodon.android.R;
@@ -23,17 +24,18 @@ import me.grishka.appkit.imageloader.ImageLoaderViewHolder;
import me.grishka.appkit.imageloader.requests.ImageLoaderRequest;
public class SpoilerStatusDisplayItem extends StatusDisplayItem{
public final Status status;
public final ArrayList<StatusDisplayItem> contentItems=new ArrayList<>();
private final CharSequence parsedTitle;
private CharSequence translatedTitle;
private final CustomEmojiHelper emojiHelper;
private final Type type;
private final int attachmentCount;
public SpoilerStatusDisplayItem(String parentID, BaseStatusListFragment parentFragment, String title, Status status, Status statusForContent, Type type){
public SpoilerStatusDisplayItem(String parentID, BaseStatusListFragment<?> parentFragment, String title, Status statusForContent, Type type){
super(parentID, parentFragment);
this.status=status;
this.status=statusForContent;
this.type=type;
this.attachmentCount=statusForContent.mediaAttachments.size();
if(TextUtils.isEmpty(title)){
parsedTitle=HtmlParser.parseCustomEmoji(statusForContent.spoilerText, statusForContent.emojis);
emojiHelper=new CustomEmojiHelper();
@@ -62,12 +64,14 @@ public class SpoilerStatusDisplayItem extends StatusDisplayItem{
public static class Holder extends StatusDisplayItem.Holder<SpoilerStatusDisplayItem> implements ImageLoaderViewHolder{
private final TextView title, action;
private final View button;
private final ImageView mediaIcon;
public Holder(Context context, ViewGroup parent, Type type){
super(context, R.layout.display_item_spoiler, parent);
title=findViewById(R.id.spoiler_title);
action=findViewById(R.id.spoiler_action);
button=findViewById(R.id.spoiler_button);
mediaIcon=findViewById(R.id.media_icon);
button.setOutlineProvider(OutlineProviders.roundedRect(8));
button.setClipToOutline(true);
@@ -94,7 +98,17 @@ public class SpoilerStatusDisplayItem extends StatusDisplayItem{
}else{
title.setText(item.parsedTitle);
}
action.setText(item.status.spoilerRevealed ? R.string.spoiler_hide : R.string.spoiler_show);
action.setText(item.status.spoilerRevealed ? R.string.spoiler_hide : R.string.sk_spoiler_show);
itemView.setPadding(
itemView.getPaddingLeft(),
itemView.getPaddingTop(),
itemView.getPaddingRight(),
item.inset ? itemView.getPaddingTop() : 0
);
mediaIcon.setVisibility(item.attachmentCount > 0 ? View.VISIBLE : View.GONE);
mediaIcon.setImageResource(item.attachmentCount > 1
? R.drawable.ic_fluent_image_multiple_24_regular
: R.drawable.ic_fluent_image_24_regular);
}
@Override

View File

@@ -1,54 +1,111 @@
package org.joinmastodon.android.ui.displayitems;
import static org.joinmastodon.android.api.session.AccountLocalPreferences.ShowEmojiReactions.ALWAYS;
import static org.joinmastodon.android.api.session.AccountLocalPreferences.ShowEmojiReactions.ONLY_OPENED;
import android.app.Activity;
import android.app.Fragment;
import android.content.Context;
import android.graphics.drawable.ColorDrawable;
import android.os.Bundle;
import android.text.SpannableStringBuilder;
import android.text.TextUtils;
import android.view.View;
import android.view.ViewGroup;
import androidx.annotation.NonNull;
import org.joinmastodon.android.R;
import org.joinmastodon.android.api.session.AccountLocalPreferences;
import org.joinmastodon.android.api.session.AccountSessionManager;
import org.joinmastodon.android.fragments.BaseStatusListFragment;
import org.joinmastodon.android.fragments.HashtagTimelineFragment;
import org.joinmastodon.android.fragments.HomeTabFragment;
import org.joinmastodon.android.fragments.ListTimelineFragment;
import org.joinmastodon.android.fragments.ProfileFragment;
import org.joinmastodon.android.fragments.StatusListFragment;
import org.joinmastodon.android.fragments.ThreadFragment;
import org.joinmastodon.android.model.Account;
import org.joinmastodon.android.model.Attachment;
import org.joinmastodon.android.model.DisplayItemsParent;
import org.joinmastodon.android.model.FilterAction;
import org.joinmastodon.android.model.LegacyFilter;
import org.joinmastodon.android.model.FilterContext;
import org.joinmastodon.android.model.FilterResult;
import org.joinmastodon.android.model.Notification;
import org.joinmastodon.android.model.Poll;
import org.joinmastodon.android.model.ScheduledStatus;
import org.joinmastodon.android.model.Status;
import org.joinmastodon.android.ui.PhotoLayoutHelper;
import org.joinmastodon.android.ui.text.HtmlParser;
import org.joinmastodon.android.ui.utils.UiUtils;
import org.joinmastodon.android.ui.viewholders.AccountViewHolder;
import org.joinmastodon.android.utils.StatusFilterPredicate;
import org.parceler.Parcels;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.function.Predicate;
import java.util.stream.Collectors;
import me.grishka.appkit.Nav;
import me.grishka.appkit.imageloader.requests.ImageLoaderRequest;
import me.grishka.appkit.utils.BindableViewHolder;
import me.grishka.appkit.views.UsableRecyclerView;
public abstract class StatusDisplayItem{
public final String parentID;
public final BaseStatusListFragment parentFragment;
public final BaseStatusListFragment<?> parentFragment;
public Status status;
public boolean inset;
public int index;
public boolean
hasDescendantNeighbor=false,
hasAncestoringNeighbor=false,
isMainStatus=true,
isDirectDescendant=false,
isForQuote=false;
public static final int FLAG_INSET=1;
public static final int FLAG_NO_FOOTER=1 << 1;
public static final int FLAG_CHECKABLE=1 << 2;
public static final int FLAG_MEDIA_FORCE_HIDDEN=1 << 3;
public static final int FLAG_NO_HEADER=1 << 4;
public static final int FLAG_NO_TRANSLATE=1 << 5;
public static final int FLAG_NO_EMOJI_REACTIONS=1 << 6;
public static final int FLAG_IS_FOR_QUOTE=1 << 7;
public static final int FLAG_NO_MEDIA_PREVIEW=1 << 8;
public StatusDisplayItem(String parentID, BaseStatusListFragment parentFragment){
public void setAncestryInfo(
boolean hasDescendantNeighbor,
boolean hasAncestoringNeighbor,
boolean isMainStatus,
boolean isDirectDescendant
) {
this.hasDescendantNeighbor = hasDescendantNeighbor;
this.hasAncestoringNeighbor = hasAncestoringNeighbor;
this.isMainStatus = isMainStatus;
this.isDirectDescendant = isDirectDescendant;
}
public StatusDisplayItem(String parentID, BaseStatusListFragment<?> parentFragment){
this.parentID=parentID;
this.parentFragment=parentFragment;
}
@NonNull
public String getContentStatusID(){
if(parentFragment instanceof StatusListFragment slf){
Status s=slf.getContentStatusByID(parentID);
return s!=null ? s.id : parentID;
}else{
return parentID;
}
}
public abstract Type getType();
public int getImageCount(){
@@ -70,127 +127,254 @@ public abstract class StatusDisplayItem{
case POLL_FOOTER -> new PollFooterStatusDisplayItem.Holder(activity, parent);
case CARD_LARGE -> new LinkCardStatusDisplayItem.Holder(activity, parent, true);
case CARD_COMPACT -> new LinkCardStatusDisplayItem.Holder(activity, parent, false);
case EMOJI_REACTIONS -> new EmojiReactionsStatusDisplayItem.Holder(activity, parent);
case FOOTER -> new FooterStatusDisplayItem.Holder(activity, parent);
case ACCOUNT_CARD -> new AccountCardStatusDisplayItem.Holder(activity, parent);
case ACCOUNT -> new AccountStatusDisplayItem.Holder(new AccountViewHolder(parentFragment, parent, null));
case HASHTAG -> new HashtagStatusDisplayItem.Holder(activity, parent);
case GAP -> new GapStatusDisplayItem.Holder(activity, parent);
case EXTENDED_FOOTER -> new ExtendedFooterStatusDisplayItem.Holder(activity, parent);
case MEDIA_GRID -> new MediaGridStatusDisplayItem.Holder(activity, parent);
case PREVIEWLESS_MEDIA_GRID -> new PreviewlessMediaGridStatusDisplayItem.Holder(activity, parent);
case WARNING -> new WarningFilteredStatusDisplayItem.Holder(activity, parent);
case FILE -> new FileStatusDisplayItem.Holder(activity, parent);
case SPOILER, FILTER_SPOILER -> new SpoilerStatusDisplayItem.Holder(activity, parent, type);
case SECTION_HEADER -> new SectionHeaderStatusDisplayItem.Holder(activity, parent);
case SECTION_HEADER -> null; // new SectionHeaderStatusDisplayItem.Holder(activity, parent);
case NOTIFICATION_HEADER -> new NotificationHeaderStatusDisplayItem.Holder(activity, parent);
case DUMMY -> new DummyStatusDisplayItem.Holder(activity);
};
}
public static ArrayList<StatusDisplayItem> buildItems(BaseStatusListFragment fragment, Status status, String accountID, DisplayItemsParent parentObject, Map<String, Account> knownAccounts, boolean inset, boolean addFooter){
int flags=0;
if(inset)
flags|=FLAG_INSET;
if(!addFooter)
flags|=FLAG_NO_FOOTER;
return buildItems(fragment, status, accountID, parentObject, knownAccounts, flags);
public static ReblogOrReplyLineStatusDisplayItem buildReplyLine(BaseStatusListFragment<?> fragment, Status status, String accountID, DisplayItemsParent parent, Account account, boolean threadReply) {
String parentID = parent.getID();
String text = threadReply ? fragment.getString(R.string.sk_show_thread)
: account == null ? fragment.getString(R.string.sk_in_reply)
: status.reblog != null ? account.getDisplayName()
: fragment.getString(R.string.in_reply_to, account.getDisplayName());
String fullText = threadReply ? fragment.getString(R.string.sk_show_thread)
: account == null ? fragment.getString(R.string.sk_in_reply)
: fragment.getString(R.string.in_reply_to, account.getDisplayName());
return new ReblogOrReplyLineStatusDisplayItem(
parentID, fragment, text, account == null ? List.of() : account.emojis,
R.drawable.ic_fluent_arrow_reply_20sp_filled, null, null, fullText, status
);
}
public static ArrayList<StatusDisplayItem> buildItems(BaseStatusListFragment fragment, Status status, String accountID, DisplayItemsParent parentObject, Map<String, Account> knownAccounts, int flags){
public static ArrayList<StatusDisplayItem> buildItems(BaseStatusListFragment<?> fragment, Status status, String accountID, DisplayItemsParent parentObject, Map<String, Account> knownAccounts, FilterContext filterContext, int flags){
String parentID=parentObject.getID();
ArrayList<StatusDisplayItem> items=new ArrayList<>();
Status statusForContent=status.getContentStatus();
Bundle args=new Bundle();
args.putString("account", accountID);
ScheduledStatus scheduledStatus = parentObject instanceof ScheduledStatus s ? s : null;
// Hide statuses that have a filter action of hide
if(!new StatusFilterPredicate(accountID, filterContext, FilterAction.HIDE).test(status))
return new ArrayList<StatusDisplayItem>() ;
HeaderStatusDisplayItem header=null;
boolean hideCounts=!AccountSessionManager.get(accountID).getLocalPreferences().showInteractionCounts;
if((flags & FLAG_NO_HEADER)==0){
if(status.reblog!=null){
items.add(new ReblogOrReplyLineStatusDisplayItem(parentID, fragment, fragment.getString(R.string.user_boosted, status.account.displayName), status.account.emojis, R.drawable.ic_repeat_20px));
}else if(status.inReplyToAccountId!=null && knownAccounts.containsKey(status.inReplyToAccountId)){
Account account=Objects.requireNonNull(knownAccounts.get(status.inReplyToAccountId));
items.add(new ReblogOrReplyLineStatusDisplayItem(parentID, fragment, fragment.getString(R.string.in_reply_to, account.displayName), account.emojis, R.drawable.ic_reply_20px));
ReblogOrReplyLineStatusDisplayItem replyLine = null;
boolean threadReply = statusForContent.inReplyToAccountId != null &&
statusForContent.inReplyToAccountId.equals(statusForContent.account.id);
if(statusForContent.inReplyToAccountId!=null && !(threadReply && fragment instanceof ThreadFragment)){
Account account = knownAccounts.get(statusForContent.inReplyToAccountId);
replyLine = buildReplyLine(fragment, status, accountID, parentObject, account, threadReply);
}
if(status.reblog!=null){
boolean isOwnPost = AccountSessionManager.getInstance().isSelf(fragment.getAccountID(), status.account);
statusForContent.rebloggedBy = status.account;
String text=fragment.getString(R.string.user_boosted, status.account.getDisplayName());
items.add(new ReblogOrReplyLineStatusDisplayItem(parentID, fragment, text, status.account.emojis, R.drawable.ic_fluent_arrow_repeat_all_20sp_filled, isOwnPost ? status.visibility : null, i->{
args.putParcelable("profileAccount", Parcels.wrap(status.account));
Nav.go(fragment.getActivity(), ProfileFragment.class, args);
}, null, status));
} else if (!(status.tags.isEmpty() ||
fragment instanceof HashtagTimelineFragment ||
fragment instanceof ListTimelineFragment
) && fragment.getParentFragment() instanceof HomeTabFragment home) {
home.getHashtags().stream()
.filter(followed -> status.tags.stream()
.anyMatch(hashtag -> followed.name.equalsIgnoreCase(hashtag.name)))
.findAny()
// post contains a hashtag the user is following
.ifPresent(hashtag -> items.add(new ReblogOrReplyLineStatusDisplayItem(
parentID, fragment, hashtag.name, List.of(),
R.drawable.ic_fluent_number_symbol_20sp_filled, null,
i->UiUtils.openHashtagTimeline(fragment.getActivity(), accountID, hashtag),
status
)));
}
if (replyLine != null) {
Optional<ReblogOrReplyLineStatusDisplayItem> primaryLine = items.stream()
.filter(i -> i instanceof ReblogOrReplyLineStatusDisplayItem)
.map(ReblogOrReplyLineStatusDisplayItem.class::cast)
.findFirst();
if (primaryLine.isPresent()) {
primaryLine.get().extra = replyLine;
} else {
items.add(replyLine);
}
}
if((flags & FLAG_CHECKABLE)!=0)
items.add(header=new CheckableHeaderStatusDisplayItem(parentID, statusForContent.account, statusForContent.createdAt, fragment, accountID, statusForContent, null));
else
items.add(header=new HeaderStatusDisplayItem(parentID, statusForContent.account, statusForContent.createdAt, fragment, accountID, statusForContent, null));
items.add(header=new HeaderStatusDisplayItem(parentID, statusForContent.account, statusForContent.createdAt, fragment, accountID, statusForContent, null, parentObject instanceof Notification n ? n : null, scheduledStatus));
}
boolean filtered=false;
LegacyFilter applyingFilter=null;
if(status.filtered!=null){
for(FilterResult filter:status.filtered){
if(filter.filter.isActive()){
filtered=true;
LegacyFilter f=filter.filter;
if(f.isActive() && filterContext != null && f.context.contains(filterContext)){
applyingFilter=f;
break;
}
}
}
ArrayList<StatusDisplayItem> contentItems;
if(filtered){
SpoilerStatusDisplayItem spoilerItem=new SpoilerStatusDisplayItem(parentID, fragment, fragment.getString(R.string.post_matches_filter_x, status.filtered.get(0).filter.title), status, statusForContent, Type.FILTER_SPOILER);
if(statusForContent.hasSpoiler()){
if (AccountSessionManager.get(accountID).getLocalPreferences().revealCWs) statusForContent.spoilerRevealed = true;
SpoilerStatusDisplayItem spoilerItem=new SpoilerStatusDisplayItem(parentID, fragment, null, statusForContent, Type.SPOILER);
if((flags & FLAG_IS_FOR_QUOTE)!=0){
for(StatusDisplayItem item:spoilerItem.contentItems){
item.isForQuote=true;
}
}
items.add(spoilerItem);
contentItems=spoilerItem.contentItems;
status.spoilerRevealed=false;
}else if(!TextUtils.isEmpty(statusForContent.spoilerText)){
SpoilerStatusDisplayItem spoilerItem=new SpoilerStatusDisplayItem(parentID, fragment, null, status, statusForContent, Type.SPOILER);
items.add(spoilerItem);
contentItems=spoilerItem.contentItems;
status.spoilerRevealed=!AccountSessionManager.get(accountID).getLocalPreferences().showCWs;
}else{
contentItems=items;
}
if(statusForContent.quote!=null) {
int quoteInlineIndex=statusForContent.content.lastIndexOf("<span class=\"quote-inline\"><br/><br/>RE:");
if (quoteInlineIndex!=-1)
statusForContent.content=statusForContent.content.substring(0, quoteInlineIndex);
}
boolean hasSpoiler=!TextUtils.isEmpty(statusForContent.spoilerText);
if(!TextUtils.isEmpty(statusForContent.content)){
SpannableStringBuilder parsedText=HtmlParser.parse(statusForContent.content, statusForContent.emojis, statusForContent.mentions, statusForContent.tags, accountID, statusForContent);
if(filtered){
HtmlParser.applyFilterHighlights(fragment.getActivity(), parsedText, status.filtered);
}
TextStatusDisplayItem text=new TextStatusDisplayItem(parentID, parsedText, fragment, statusForContent);
text.reduceTopPadding=header==null;
SpannableStringBuilder parsedText=HtmlParser.parse(statusForContent.content, statusForContent.emojis, statusForContent.mentions, statusForContent.tags, accountID, fragment.getContext());
HtmlParser.applyFilterHighlights(fragment.getActivity(), parsedText, status.filtered);
TextStatusDisplayItem text=new TextStatusDisplayItem(parentID, HtmlParser.parse(statusForContent.content, statusForContent.emojis, statusForContent.mentions, statusForContent.tags, accountID, fragment.getContext()), fragment, statusForContent, (flags & FLAG_NO_TRANSLATE) != 0);
contentItems.add(text);
}else if(header!=null){
}else if(!hasSpoiler && header!=null){
header.needBottomPadding=true;
}else if(hasSpoiler){
contentItems.add(new DummyStatusDisplayItem(parentID, fragment));
}
List<Attachment> imageAttachments=statusForContent.mediaAttachments.stream().filter(att->att.type.isImage()).collect(Collectors.toList());
if(!imageAttachments.isEmpty()){
if(!imageAttachments.isEmpty() && (flags & FLAG_NO_MEDIA_PREVIEW)==0){
int color = UiUtils.getThemeColor(fragment.getContext(), R.attr.colorM3SurfaceVariant);
for (Attachment att : imageAttachments) {
if (att.blurhashPlaceholder == null) {
att.blurhashPlaceholder = new ColorDrawable(color);
}
}
PhotoLayoutHelper.TiledLayoutResult layout=PhotoLayoutHelper.processThumbs(imageAttachments);
MediaGridStatusDisplayItem mediaGrid=new MediaGridStatusDisplayItem(parentID, fragment, layout, imageAttachments, statusForContent);
if((flags & FLAG_MEDIA_FORCE_HIDDEN)!=0)
if((flags & FLAG_MEDIA_FORCE_HIDDEN)!=0){
mediaGrid.sensitiveTitle=fragment.getString(R.string.media_hidden);
else if(statusForContent.sensitive && !AccountSessionManager.get(accountID).getLocalPreferences().hideSensitiveMedia)
mediaGrid.sensitiveRevealed=true;
statusForContent.sensitiveRevealed=false;
statusForContent.sensitive=true;
} else if(statusForContent.sensitive && AccountSessionManager.get(accountID).getLocalPreferences().revealCWs && !AccountSessionManager.get(accountID).getLocalPreferences().hideSensitiveMedia)
statusForContent.sensitiveRevealed=true;
contentItems.add(mediaGrid);
}
if((flags & FLAG_NO_MEDIA_PREVIEW)!=0){
contentItems.add(new PreviewlessMediaGridStatusDisplayItem(parentID, fragment, null, imageAttachments, statusForContent));
}
for(Attachment att:statusForContent.mediaAttachments){
if(att.type==Attachment.Type.AUDIO){
contentItems.add(new AudioStatusDisplayItem(parentID, fragment, statusForContent, att));
}
if(att.type==Attachment.Type.UNKNOWN){
contentItems.add(new FileStatusDisplayItem(parentID, fragment, att));
}
}
if(statusForContent.poll!=null){
buildPollItems(parentID, fragment, statusForContent.poll, status, contentItems);
}
if(statusForContent.card!=null && statusForContent.mediaAttachments.isEmpty() && TextUtils.isEmpty(statusForContent.spoilerText)){
contentItems.add(new LinkCardStatusDisplayItem(parentID, fragment, statusForContent));
if(statusForContent.card!=null && statusForContent.mediaAttachments.isEmpty() && statusForContent.quote==null && !statusForContent.card.isHashtagUrl(statusForContent.url)){
contentItems.add(new LinkCardStatusDisplayItem(parentID, fragment, statusForContent, (flags & FLAG_NO_MEDIA_PREVIEW)==0));
}
if(contentItems!=items && status.spoilerRevealed){
if(statusForContent.quote!=null && !(parentObject instanceof Notification)){
if(!statusForContent.mediaAttachments.isEmpty() && statusForContent.poll==null) // add spacing if immediately preceded by attachment
contentItems.add(new DummyStatusDisplayItem(parentID, fragment));
contentItems.addAll(buildItems(fragment, statusForContent.quote, accountID, parentObject, knownAccounts, filterContext, FLAG_NO_FOOTER | FLAG_INSET | FLAG_NO_EMOJI_REACTIONS | FLAG_IS_FOR_QUOTE));
}
if(contentItems!=items && statusForContent.spoilerRevealed){
items.addAll(contentItems);
}
AccountLocalPreferences lp=fragment.getLocalPrefs();
if((flags & FLAG_NO_EMOJI_REACTIONS)==0 && !status.preview && lp.emojiReactionsEnabled &&
(lp.showEmojiReactions!=ONLY_OPENED || fragment instanceof ThreadFragment) &&
statusForContent.reactions!=null){
boolean isMainStatus=fragment instanceof ThreadFragment t && t.getMainStatus().id.equals(statusForContent.id);
boolean showAddButton=lp.showEmojiReactions==ALWAYS || isMainStatus;
items.add(new EmojiReactionsStatusDisplayItem(parentID, fragment, statusForContent, accountID, !showAddButton, false));
}
FooterStatusDisplayItem footer=null;
if((flags & FLAG_NO_FOOTER)==0){
FooterStatusDisplayItem footer=new FooterStatusDisplayItem(parentID, fragment, statusForContent, accountID);
footer=new FooterStatusDisplayItem(parentID, fragment, statusForContent, accountID);
footer.hideCounts=hideCounts;
items.add(footer);
if(status.hasGapAfter && !(fragment instanceof ThreadFragment))
items.add(new GapStatusDisplayItem(parentID, fragment));
}
int i=1;
boolean inset=(flags & FLAG_INSET)!=0;
boolean isForQuote=(flags & FLAG_IS_FOR_QUOTE)!=0;
// add inset dummy so last content item doesn't clip out of inset bounds
if((inset || footer==null) && (flags & FLAG_CHECKABLE)==0 && !isForQuote){
items.add(new DummyStatusDisplayItem(parentID, fragment));
// in case we ever need the dummy to display a margin for the media grid again:
// (i forgot why we apparently don't need this anymore)
// !contentItems.isEmpty() && contentItems
// .get(contentItems.size() - 1) instanceof MediaGridStatusDisplayItem));
}
GapStatusDisplayItem gap=null;
if((flags & FLAG_NO_FOOTER)==0 && status.hasGapAfter!=null && !(fragment instanceof ThreadFragment))
items.add(gap=new GapStatusDisplayItem(parentID, fragment, status));
int i=1;
for(StatusDisplayItem item:items){
item.inset=inset;
if(inset)
item.inset=true;
if(isForQuote){
item.status=statusForContent;
item.isForQuote=true;
}
item.index=i++;
}
if(items!=contentItems){
if(items!=contentItems && !statusForContent.spoilerRevealed){
for(StatusDisplayItem item:contentItems){
item.inset=inset;
if(inset)
item.inset=true;
if(isForQuote){
item.status=statusForContent;
item.isForQuote=true;
}
item.index=i++;
}
}
return items;
List<StatusDisplayItem> nonGapItems=gap!=null ? items.subList(0, items.size()-1) : items;
WarningFilteredStatusDisplayItem warning=applyingFilter==null ? null :
new WarningFilteredStatusDisplayItem(parentID, fragment, statusForContent, nonGapItems, applyingFilter);
return applyingFilter==null ? items : new ArrayList<>(gap!=null
? List.of(warning, gap)
: Collections.singletonList(warning)
);
}
public static void buildPollItems(String parentID, BaseStatusListFragment fragment, Poll poll, Status status, List<StatusDisplayItem> items){
@@ -199,7 +383,7 @@ public abstract class StatusDisplayItem{
items.add(new PollOptionStatusDisplayItem(parentID, poll, i, fragment, status));
i++;
}
items.add(new PollFooterStatusDisplayItem(parentID, fragment, poll));
items.add(new PollFooterStatusDisplayItem(parentID, fragment, poll, status));
}
public enum Type{
@@ -211,26 +395,35 @@ public abstract class StatusDisplayItem{
POLL_FOOTER,
CARD_LARGE,
CARD_COMPACT,
EMOJI_REACTIONS,
FOOTER,
ACCOUNT_CARD,
ACCOUNT,
HASHTAG,
GAP,
EXTENDED_FOOTER,
MEDIA_GRID,
PREVIEWLESS_MEDIA_GRID,
WARNING,
FILE,
SPOILER,
SECTION_HEADER,
HEADER_CHECKABLE,
NOTIFICATION_HEADER,
FILTER_SPOILER
FILTER_SPOILER,
DUMMY
}
public static abstract class Holder<T extends StatusDisplayItem> extends BindableViewHolder<T> implements UsableRecyclerView.DisableableClickable{
private Context context;
public Holder(View itemView){
super(itemView);
}
public Holder(Context context, int layout, ViewGroup parent){
super(context, layout, parent);
this.context=context;
}
public String getItemID(){
@@ -239,12 +432,54 @@ public abstract class StatusDisplayItem{
@Override
public void onClick(){
if(item.isForQuote){
item.status.filterRevealed=true;
Bundle args=new Bundle();
args.putString("account", item.parentFragment.getAccountID());
args.putParcelable("status", Parcels.wrap(item.status.clone()));
args.putBoolean("refresh", true);
Nav.go((Activity) context, ThreadFragment.class, args);
return;
}
item.parentFragment.onItemClick(item.parentID);
}
public Optional<StatusDisplayItem> getNextVisibleDisplayItem(){
return getNextVisibleDisplayItem(null);
}
public Optional<StatusDisplayItem> getNextVisibleDisplayItem(Predicate<StatusDisplayItem> predicate){
Optional<StatusDisplayItem> next=getNextDisplayItem();
for(int offset=1; next.isPresent(); next=getDisplayItemOffset(++offset)){
boolean isHidden=next.map(n->(n instanceof EmojiReactionsStatusDisplayItem e && e.isHidden())
|| (n instanceof DummyStatusDisplayItem)).orElse(false);
if(!isHidden && (predicate==null || predicate.test(next.get()))) return next;
}
return Optional.empty();
}
public Optional<StatusDisplayItem> getNextDisplayItem(){
return getDisplayItemOffset(1);
}
public Optional<StatusDisplayItem> getDisplayItemOffset(int offset){
List<StatusDisplayItem> displayItems=item.parentFragment.getDisplayItems();
int thisPos=displayItems.indexOf(item);
int offsetPos=thisPos + offset;
return displayItems.size() > offsetPos && thisPos >= 0 && offsetPos >= 0
? Optional.of(displayItems.get(offsetPos))
: Optional.empty();
}
public boolean isLastDisplayItemForStatus(){
return getNextVisibleDisplayItem()
.map(next->!next.parentID.equals(item.parentID) || item.inset && !next.inset)
.orElse(true);
}
@Override
public boolean isEnabled(){
return item.parentFragment.isItemEnabled(item.parentID);
return item.parentFragment.isItemEnabled(item.parentID) || item.isForQuote;
}
}
}

View File

@@ -3,27 +3,35 @@ package org.joinmastodon.android.ui.displayitems;
import android.app.Activity;
import android.graphics.drawable.Animatable;
import android.graphics.drawable.Drawable;
import android.text.SpannableStringBuilder;
import android.view.View;
import android.view.ViewGroup;
import android.view.ViewStub;
import android.widget.Button;
import android.widget.LinearLayout;
import android.widget.ProgressBar;
import android.widget.ScrollView;
import android.widget.TextView;
import org.joinmastodon.android.GlobalUserPreferences;
import org.joinmastodon.android.R;
import org.joinmastodon.android.api.session.AccountSessionManager;
import org.joinmastodon.android.fragments.BaseStatusListFragment;
import org.joinmastodon.android.model.Status;
import org.joinmastodon.android.model.Translation;
import org.joinmastodon.android.ui.text.HtmlParser;
import org.joinmastodon.android.ui.utils.CustomEmojiHelper;
import org.joinmastodon.android.ui.utils.UiUtils;
import org.joinmastodon.android.ui.views.LinkedTextView;
import java.util.Locale;
import java.util.regex.Pattern;
import me.grishka.appkit.api.Callback;
import me.grishka.appkit.api.ErrorResponse;
import me.grishka.appkit.imageloader.ImageLoaderViewHolder;
import me.grishka.appkit.imageloader.MovieDrawable;
import me.grishka.appkit.imageloader.requests.ImageLoaderRequest;
import me.grishka.appkit.utils.CubicBezierInterpolator;
import me.grishka.appkit.utils.V;
public class TextStatusDisplayItem extends StatusDisplayItem{
@@ -33,12 +41,13 @@ public class TextStatusDisplayItem extends StatusDisplayItem{
private CustomEmojiHelper translationEmojiHelper=new CustomEmojiHelper();
public boolean textSelectable;
public boolean reduceTopPadding;
public final Status status;
public boolean disableTranslate;
public TextStatusDisplayItem(String parentID, CharSequence text, BaseStatusListFragment parentFragment, Status status){
public TextStatusDisplayItem(String parentID, CharSequence text, BaseStatusListFragment parentFragment, Status status, boolean disableTranslate){
super(parentID, parentFragment);
this.text=text;
this.status=status;
this.disableTranslate=disableTranslate;
emojiHelper.setText(text);
}
@@ -70,15 +79,29 @@ public class TextStatusDisplayItem extends StatusDisplayItem{
public static class Holder extends StatusDisplayItem.Holder<TextStatusDisplayItem> implements ImageLoaderViewHolder{
private final LinkedTextView text;
private final ViewStub translationFooterStub;
private View translationFooter;
private View translationFooter, translationButtonWrap;
private TextView translationInfo;
private Button translationShowOriginal;
private Button translationButton;
private ProgressBar translationProgress;
private final float textMaxHeight;
private final LinearLayout.LayoutParams collapseParams, wrapParams;
private final ViewGroup parent;
private final TextView readMore;
private final ScrollView textScrollView;
public Holder(Activity activity, ViewGroup parent){
super(activity, R.layout.display_item_text, parent);
this.parent=parent;
text=findViewById(R.id.text);
translationFooterStub=findViewById(R.id.translation_info);
textScrollView=findViewById(R.id.text_scroll_view);
readMore=findViewById(R.id.read_more);
textMaxHeight=activity.getResources().getDimension(R.dimen.text_max_height);
float textCollapsedHeight=activity.getResources().getDimension(R.dimen.text_collapsed_height);
collapseParams=new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, (int) textCollapsedHeight);
wrapParams=new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT);
readMore.setOnClickListener(v -> item.parentFragment.onToggleExpanded(item.status, getItemID()));
}
@Override
@@ -91,12 +114,60 @@ public class TextStatusDisplayItem extends StatusDisplayItem{
}else{
text.setText(item.text);
}
text.setTextIsSelectable(item.textSelectable);
text.setTextIsSelectable(false);
if(item.textSelectable && !item.isForQuote) itemView.post(() -> text.setTextIsSelectable(true));
text.setInvalidateOnEveryFrame(false);
itemView.setClickable(false);
text.setPadding(text.getPaddingLeft(), item.reduceTopPadding ? V.dp(8) : V.dp(16), text.getPaddingRight(), text.getPaddingBottom());
itemView.setPadding(itemView.getPaddingLeft(), item.reduceTopPadding ? V.dp(6) : V.dp(12), itemView.getPaddingRight(), itemView.getPaddingBottom());
text.setTextColor(UiUtils.getThemeColor(text.getContext(), item.inset ? R.attr.colorM3OnSurfaceVariant : R.attr.colorM3OnSurface));
updateTranslation(false);
readMore.setText(item.status.textExpanded ? R.string.sk_collapse : R.string.sk_expand);
StatusDisplayItem next=getNextVisibleDisplayItem().orElse(null);
if(next!=null && !next.parentID.equals(item.parentID)) next=null;
int bottomPadding=item.inset ? V.dp(12)
: next instanceof FooterStatusDisplayItem ? V.dp(6)
: (next instanceof EmojiReactionsStatusDisplayItem || next==null) ? 0
: V.dp(12);
itemView.setPadding(itemView.getPaddingLeft(), itemView.getPaddingTop(), itemView.getPaddingRight(), bottomPadding);
if (!GlobalUserPreferences.collapseLongPosts) {
textScrollView.setLayoutParams(wrapParams);
readMore.setVisibility(View.GONE);
}
// incredibly ugly workaround for https://github.com/sk22/megalodon/issues/520
// i am so, so sorry. FIXME
// attempts to use OnPreDrawListener, OnGlobalLayoutListener and .post have failed -
// the view didn't want to reliably update after calling .setVisibility etc :(
int width = parent.getWidth() != 0 ? parent.getWidth()
: item.parentFragment.getView().getWidth() != 0
? item.parentFragment.getView().getWidth()
: item.parentFragment.getParentFragment() != null && item.parentFragment.getParentFragment().getView().getWidth() != 0
? item.parentFragment.getParentFragment().getView().getWidth() // YIKES
: UiUtils.MAX_WIDTH;
text.measure(
View.MeasureSpec.makeMeasureSpec(width, View.MeasureSpec.EXACTLY),
View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED));
if (GlobalUserPreferences.collapseLongPosts && !item.status.textExpandable) {
boolean tooBig = text.getMeasuredHeight() > textMaxHeight;
boolean expandable = tooBig && !item.status.hasSpoiler();
item.parentFragment.onEnableExpandable(Holder.this, expandable);
}
boolean expandButtonShown=item.status.textExpandable && !item.status.textExpanded;
if(translationFooter!=null)
translationFooter.setPadding(0, V.dp(expandButtonShown ? 0 : 4), 0, 0);
readMore.setVisibility(expandButtonShown ? View.VISIBLE : View.GONE);
textScrollView.setLayoutParams(item.status.textExpandable && !item.status.textExpanded ? collapseParams : wrapParams);
// compensate for spoiler's bottom margin
ViewGroup.MarginLayoutParams params = (ViewGroup.MarginLayoutParams) itemView.getLayoutParams();
params.setMargins(params.leftMargin, item.inset && item.status.hasSpoiler() ? V.dp(-16) : 0,
params.rightMargin, params.bottomMargin);
}
@Override
@@ -123,27 +194,45 @@ public class TextStatusDisplayItem extends StatusDisplayItem{
public void updateTranslation(boolean updateText){
if(item.status==null)
return;
boolean translateEnabled=!item.disableTranslate && item.status.isEligibleForTranslation(item.parentFragment.getSession()) && !item.isForQuote;
if(translationFooter==null && translateEnabled){
translationFooter=translationFooterStub.inflate();
translationInfo=findViewById(R.id.translation_info_text);
translationButton=findViewById(R.id.translation_btn);
translationButtonWrap=findViewById(R.id.translation_btn_wrap);
translationProgress=findViewById(R.id.translation_progress);
translationButton.setOnClickListener(v->item.parentFragment.togglePostTranslation(item.status, item.parentID));
}
if(translationButton!=null) translationButton.animate().cancel();
if(item.status.translationState==Status.TranslationState.HIDDEN){
if(translationFooter!=null)
translationFooter.setVisibility(View.GONE);
if(updateText){
text.setText(item.text);
}
if(updateText) text.setText(item.text);
if(translationFooter==null) return;
translationFooter.setVisibility(translateEnabled ? View.VISIBLE : View.GONE);
translationProgress.setVisibility(View.GONE);
Translation existingTrans=item.status.getContentStatus().translation;
String existingTransLang=existingTrans!=null ? existingTrans.detectedSourceLanguage : null;
String lang=existingTransLang!=null ? existingTransLang : item.status.getContentStatus().language;
Locale locale=lang!=null ? Locale.forLanguageTag(lang) : null;
String displayLang=locale==null || locale.getDisplayLanguage().isBlank() ? lang : locale.getDisplayLanguage();
translationButton.setText(displayLang!=null
? item.parentFragment.getString(R.string.translate_post, displayLang)
: item.parentFragment.getString(R.string.sk_translate_post));
translationButton.setClickable(true);
translationButton.animate().alpha(1).setDuration(100).start();
translationInfo.setVisibility(View.GONE);
UiUtils.beginLayoutTransition((ViewGroup) translationButtonWrap);
}else{
if(translationFooter==null){
translationFooter=translationFooterStub.inflate();
translationInfo=findViewById(R.id.translation_info_text);
translationShowOriginal=findViewById(R.id.translation_show_original);
translationProgress=findViewById(R.id.translation_progress);
translationShowOriginal.setOnClickListener(v->item.parentFragment.togglePostTranslation(item.status, item.parentID));
}else{
translationFooter.setVisibility(View.VISIBLE);
}
translationFooter.setVisibility(View.VISIBLE);
if(item.status.translationState==Status.TranslationState.SHOWN){
translationProgress.setVisibility(View.GONE);
translationButton.setText(R.string.translation_show_original);
translationButton.setClickable(true);
translationButton.animate().alpha(1).setDuration(200).start();
translationInfo.setVisibility(View.VISIBLE);
translationShowOriginal.setVisibility(View.VISIBLE);
translationInfo.setText(translationInfo.getContext().getString(R.string.post_translated, Locale.forLanguageTag(item.status.translation.detectedSourceLanguage).getDisplayLanguage(), item.status.translation.provider));
translationButton.setVisibility(View.VISIBLE);
String displayLang=Locale.forLanguageTag(item.status.translation.detectedSourceLanguage).getDisplayLanguage();
translationInfo.setText(translationInfo.getContext().getString(R.string.post_translated, !displayLang.isBlank() ? displayLang : item.status.translation.detectedSourceLanguage, item.status.translation.provider));
UiUtils.beginLayoutTransition((ViewGroup) translationButtonWrap);
if(updateText){
if(item.translatedText==null){
item.setTranslatedText(item.status.translation.content);
@@ -152,8 +241,10 @@ public class TextStatusDisplayItem extends StatusDisplayItem{
}
}else{ // LOADING
translationProgress.setVisibility(View.VISIBLE);
translationButton.setClickable(false);
translationButton.animate().alpha(UiUtils.ALPHA_PRESSED).setStartDelay(50).setDuration(300).setInterpolator(CubicBezierInterpolator.DEFAULT).start();
translationInfo.setVisibility(View.INVISIBLE);
translationShowOriginal.setVisibility(View.INVISIBLE);
UiUtils.beginLayoutTransition((ViewGroup) translationButton.getParent());
}
}
}

View File

@@ -0,0 +1,63 @@
package org.joinmastodon.android.ui.displayitems;
import android.content.Context;
import android.view.View;
import android.view.ViewGroup;
import android.widget.Button;
import android.widget.TextView;
import org.joinmastodon.android.R;
import org.joinmastodon.android.fragments.BaseStatusListFragment;
import org.joinmastodon.android.model.AltTextFilter;
import org.joinmastodon.android.model.Filter;
import org.joinmastodon.android.model.LegacyFilter;
import org.joinmastodon.android.model.Status;
import java.util.List;
// Mind the gap!
public class WarningFilteredStatusDisplayItem extends StatusDisplayItem{
public boolean loading;
public List<StatusDisplayItem> filteredItems;
public LegacyFilter applyingFilter;
public WarningFilteredStatusDisplayItem(String parentID, BaseStatusListFragment<?> parentFragment, Status status, List<StatusDisplayItem> filteredItems, LegacyFilter applyingFilter){
super(parentID, parentFragment);
this.status=status;
this.filteredItems = filteredItems;
this.applyingFilter = applyingFilter;
}
@Override
public Type getType(){
return Type.WARNING;
}
public static class Holder extends StatusDisplayItem.Holder<WarningFilteredStatusDisplayItem>{
public final View warningWrap;
public final Button showBtn;
public final TextView text;
public List<StatusDisplayItem> filteredItems;
public Holder(Context context, ViewGroup parent){
super(context, R.layout.display_item_warning, parent);
warningWrap=findViewById(R.id.warning_wrap);
showBtn=findViewById(R.id.reveal_btn);
showBtn.setOnClickListener(i -> item.parentFragment.onWarningClick(this));
itemView.setOnClickListener(v->item.parentFragment.onWarningClick(this));
text=findViewById(R.id.text);
}
@Override
public void onBind(WarningFilteredStatusDisplayItem item) {
filteredItems = item.filteredItems;
String title = item.applyingFilter instanceof AltTextFilter ? item.parentFragment.getString(R.string.sk_no_alt_text) : item.applyingFilter.title;
text.setText(item.parentFragment.getString(R.string.sk_filtered, title));
}
@Override
public void onClick(){
}
}
}

View File

@@ -23,9 +23,9 @@ import me.grishka.appkit.utils.V;
public class SawtoothTearDrawable extends Drawable{
private final Paint topPaint, bottomPaint;
private static final int TOP_SAWTOOTH_HEIGHT=5;
private static final int BOTTOM_SAWTOOTH_HEIGHT=3;
private static final int STROKE_WIDTH=2;
private static final int TOP_SAWTOOTH_HEIGHT=4;
private static final int BOTTOM_SAWTOOTH_HEIGHT=4;
private static final int STROKE_WIDTH=1;
private static final int SAWTOOTH_PERIOD=14;
public SawtoothTearDrawable(Context context){

View File

@@ -52,18 +52,22 @@ import android.widget.TextView;
import android.widget.Toast;
import android.widget.Toolbar;
import org.joinmastodon.android.GlobalUserPreferences;
import org.joinmastodon.android.R;
import org.joinmastodon.android.api.MastodonAPIController;
import org.joinmastodon.android.api.session.AccountSessionManager;
import org.joinmastodon.android.events.StatusCountersUpdatedEvent;
import org.joinmastodon.android.model.Attachment;
import org.joinmastodon.android.ui.ImageDescriptionSheet;
import org.joinmastodon.android.model.Status;
import org.joinmastodon.android.ui.M3AlertDialogBuilder;
import org.joinmastodon.android.utils.FileProvider;
import org.joinmastodon.android.ui.utils.UiUtils;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.util.ArrayList;
import java.util.List;
@@ -81,6 +85,9 @@ import me.grishka.appkit.utils.BindableViewHolder;
import me.grishka.appkit.utils.CubicBezierInterpolator;
import me.grishka.appkit.utils.V;
import me.grishka.appkit.views.FragmentRootLinearLayout;
import okhttp3.OkHttpClient;
import okhttp3.Request;
import okhttp3.Response;
import okio.BufferedSink;
import okio.Okio;
import okio.Sink;
@@ -110,6 +117,7 @@ public class PhotoViewer implements ZoomPanView.Listener{
private TextView videoTimeView;
private ImageButton videoPlayPauseButton;
private View videoControls;
private MenuItem imageDescriptionButton;
private boolean uiVisible=true;
private AudioManager.OnAudioFocusChangeListener audioFocusListener=this::onAudioFocusChanged;
private Runnable uiAutoHider=()->{
@@ -172,7 +180,7 @@ public class PhotoViewer implements ZoomPanView.Listener{
toolbarWrap.setPadding(0, 0, 0, 0);
videoControls.setPadding(0, 0, 0, 0);
}
insets=insets.replaceSystemWindowInsets(tappable.left, tappable.top, tappable.right, tappable.bottom);
insets=insets.replaceSystemWindowInsets(tappable.left, tappable.top, tappable.right, insets.getSystemWindowInsetBottom());
}
uiOverlay.dispatchApplyWindowInsets(insets);
int bottomInset=insets.getSystemWindowInsetBottom();
@@ -202,10 +210,36 @@ public class PhotoViewer implements ZoomPanView.Listener{
toolbarWrap=uiOverlay.findViewById(R.id.toolbar_wrap);
toolbar=uiOverlay.findViewById(R.id.toolbar);
toolbar.setNavigationOnClickListener(v->onStartSwipeToDismissTransition(0));
imageDescriptionButton = toolbar.getMenu()
.add(R.string.sk_image_description)
.setIcon(R.drawable.ic_fluent_image_alt_text_24_regular)
.setVisible(attachments.get(pager.getCurrentItem()).description != null
&& !attachments.get(pager.getCurrentItem()).description.isEmpty())
.setOnMenuItemClickListener(item -> {
new ImageDescriptionSheet(activity,attachments.get(pager.getCurrentItem())).show();
return true;
});
imageDescriptionButton.setShowAsAction(MenuItem.SHOW_AS_ACTION_ALWAYS);
toolbar.getMenu()
.add(R.string.download)
.setIcon(R.drawable.ic_fluent_arrow_download_24_regular)
.setOnMenuItemClickListener(item -> {
saveCurrentFile();
return true;
})
.setShowAsAction(MenuItem.SHOW_AS_ACTION_ALWAYS);
toolbar.getMenu()
.add(R.string.button_share)
.setIcon(R.drawable.ic_fluent_share_24_regular)
.setOnMenuItemClickListener(item -> {
shareCurrentFile();
return true;
})
.setShowAsAction(MenuItem.SHOW_AS_ACTION_ALWAYS);
if(status!=null)
toolbar.getMenu().add(R.string.info).setIcon(R.drawable.ic_info_24px).setShowAsAction(MenuItem.SHOW_AS_ACTION_ALWAYS);
toolbar.getMenu().add(R.string.info).setIcon(R.drawable.ic_fluent_info_24_regular).setShowAsAction(MenuItem.SHOW_AS_ACTION_ALWAYS);
else
toolbar.getMenu().add(R.string.download).setIcon(R.drawable.ic_download_24px).setShowAsAction(MenuItem.SHOW_AS_ACTION_ALWAYS);
toolbar.getMenu().add(R.string.download).setIcon(R.drawable.ic_fluent_arrow_download_24_regular).setShowAsAction(MenuItem.SHOW_AS_ACTION_ALWAYS);
toolbar.setOnMenuItemClickListener(item->{
if(status!=null)
showInfoSheet();
@@ -412,6 +446,7 @@ public class PhotoViewer implements ZoomPanView.Listener{
private void onPageChanged(int index){
currentIndex=index;
Attachment att=attachments.get(index);
imageDescriptionButton.setVisible(att.description != null && !att.description.isEmpty());
V.setVisibilityAnimated(videoControls, att.type==Attachment.Type.VIDEO ? View.VISIBLE : View.GONE);
if(att.type==Attachment.Type.VIDEO){
videoSeekBar.setSecondaryProgress(0);
@@ -436,7 +471,8 @@ public class PhotoViewer implements ZoomPanView.Listener{
WindowManager.LayoutParams wlp=(WindowManager.LayoutParams) windowView.getLayoutParams();
wlp.flags|=WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON;
wm.updateViewLayout(windowView, wlp);
activity.getSystemService(AudioManager.class).requestAudioFocus(audioFocusListener, AudioManager.STREAM_MUSIC, AudioManager.AUDIOFOCUS_GAIN);
int audiofocus = GlobalUserPreferences.overlayMedia ? AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_MAY_DUCK : AudioManager.AUDIOFOCUS_GAIN;
activity.getSystemService(AudioManager.class).requestAudioFocus(audioFocusListener, AudioManager.STREAM_MUSIC, audiofocus);
}
screenOnRefCount++;
}
@@ -457,6 +493,44 @@ public class PhotoViewer implements ZoomPanView.Listener{
pauseVideo();
}
private void shareCurrentFile(){
Attachment att=attachments.get(pager.getCurrentItem());
Intent intent = new Intent(Intent.ACTION_SEND);
if(att.type==Attachment.Type.IMAGE){
UrlImageLoaderRequest req=new UrlImageLoaderRequest(att.url);
try{
File file=ImageCache.getInstance(activity).getFile(req);
if(file==null){
shareAfterDownloading(att);
return;
}
MastodonAPIController.runInBackground(()->{
File imageDir = new File(activity.getCacheDir(), ".");
File renamedFile;
file.renameTo(renamedFile = new File(imageDir, Uri.parse(att.url).getLastPathSegment()));
Uri outputUri = FileProvider.getUriForFile(activity, activity.getPackageName() + ".fileprovider", renamedFile);
// setting type to image
intent.setType(mimeTypeForFileName(outputUri.getLastPathSegment()));
intent.putExtra(Intent.EXTRA_STREAM, outputUri);
// calling startactivity() to share
activity.startActivity(Intent.createChooser(intent, activity.getString(R.string.button_share)));
});
}catch(IOException x){
Log.w(TAG, "shareCurrentFile: ", x);
Toast.makeText(activity, R.string.error, Toast.LENGTH_SHORT).show();
}
}else{
shareAfterDownloading(att);
}
}
private void saveCurrentFile(){
if(Build.VERSION.SDK_INT>=29){
doSaveCurrentFile();
@@ -561,6 +635,47 @@ public class PhotoViewer implements ZoomPanView.Listener{
Toast.makeText(activity, R.string.downloading, Toast.LENGTH_SHORT).show();
}
private void shareAfterDownloading(Attachment att){
Uri uri=Uri.parse(att.url);
MastodonAPIController.runInBackground(()->{
try {
OkHttpClient client = new OkHttpClient();
Request request = new Request.Builder().url(att.url).build();
Response response = client.newCall(request).execute();
Toast.makeText(activity, R.string.downloading, Toast.LENGTH_SHORT);
if (!response.isSuccessful()) {
throw new IOException("" + response);
}
File imageDir = new File(activity.getCacheDir(), ".");
InputStream inputStream = response.body().byteStream();
File file = new File(imageDir, uri.getLastPathSegment());
FileOutputStream outputStream = new FileOutputStream(file);
byte[] buffer = new byte[4096];
int bytesRead;
while ((bytesRead = inputStream.read(buffer)) != -1) {
outputStream.write(buffer, 0, bytesRead);
}
outputStream.close();
inputStream.close();
Intent intent = new Intent(Intent.ACTION_SEND);
Uri outputUri = FileProvider.getUriForFile(activity, activity.getPackageName() + ".fileprovider", file);
intent.setType(mimeTypeForFileName(outputUri.getLastPathSegment()));
intent.putExtra(Intent.EXTRA_STREAM, outputUri);
activity.startActivity(Intent.createChooser(intent, activity.getString(R.string.button_share)));
} catch(IOException e){
Toast.makeText(activity, R.string.error, Toast.LENGTH_SHORT).show();
}
});
}
private void onAudioFocusChanged(int change){
if(change==AudioManager.AUDIOFOCUS_LOSS || change==AudioManager.AUDIOFOCUS_LOSS_TRANSIENT || change==AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK){
pauseVideo();
@@ -585,7 +700,7 @@ public class PhotoViewer implements ZoomPanView.Listener{
if(holder==null || !holder.player.isPlaying())
return;
holder.player.pause();
videoPlayPauseButton.setImageResource(R.drawable.ic_play_24);
videoPlayPauseButton.setImageResource(R.drawable.ic_fluent_play_24_filled);
videoPlayPauseButton.setContentDescription(activity.getString(R.string.play));
stopUpdatingVideoPosition();
windowView.removeCallbacks(uiAutoHider);
@@ -599,7 +714,7 @@ public class PhotoViewer implements ZoomPanView.Listener{
if(player==null || player.isPlaying())
return;
player.start();
videoPlayPauseButton.setImageResource(R.drawable.ic_pause_24);
videoPlayPauseButton.setImageResource(R.drawable.ic_fluent_pause_24_filled);
videoPlayPauseButton.setContentDescription(activity.getString(R.string.pause));
startUpdatingVideoPosition(player);
}
@@ -682,11 +797,11 @@ public class PhotoViewer implements ZoomPanView.Listener{
public void onButtonClick(int id){
if(id==R.id.btn_boost){
if(status!=null){
AccountSessionManager.get(accountID).getStatusInteractionController().setReblogged(status, !status.reblogged);
AccountSessionManager.get(accountID).getStatusInteractionController().setReblogged(status, !status.reblogged, null, r->{});
}
}else if(id==R.id.btn_favorite){
if(status!=null){
AccountSessionManager.get(accountID).getStatusInteractionController().setFavorited(status, !status.favourited);
AccountSessionManager.get(accountID).getStatusInteractionController().setFavorited(status, !status.favourited, r->{});
}
}else if(id==R.id.btn_share){
if(status!=null){
@@ -1053,7 +1168,7 @@ public class PhotoViewer implements ZoomPanView.Listener{
@Override
public void onCompletion(MediaPlayer mp){
videoPlayPauseButton.setImageResource(R.drawable.ic_play_24);
videoPlayPauseButton.setImageResource(R.drawable.ic_fluent_play_24_filled);
videoPlayPauseButton.setContentDescription(activity.getString(R.string.play));
stopUpdatingVideoPosition();
if(!uiVisible)

View File

@@ -63,7 +63,7 @@ public class PhotoViewerInfoSheet extends BottomSheet{
}
backButton=new ImageButton(context);
backButton.setImageResource(R.drawable.ic_arrow_back);
backButton.setImageResource(R.drawable.ic_fluent_arrow_left_24_regular);
backButton.setImageTintList(ColorStateList.valueOf(UiUtils.getThemeColor(context, R.attr.colorM3OnSurfaceVariant)));
backButton.setBackgroundResource(R.drawable.bg_button_m3_tonal_icon);
backButton.setOutlineProvider(ViewOutlineProvider.BACKGROUND);

View File

@@ -1,90 +0,0 @@
package org.joinmastodon.android.ui.sheets;
import android.content.Context;
import android.content.res.ColorStateList;
import android.graphics.drawable.ColorDrawable;
import android.graphics.drawable.InsetDrawable;
import android.view.Gravity;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.Button;
import android.widget.ImageView;
import android.widget.LinearLayout;
import android.widget.TextView;
import org.joinmastodon.android.R;
import org.joinmastodon.android.model.Account;
import org.joinmastodon.android.ui.drawables.EmptyDrawable;
import org.joinmastodon.android.ui.utils.UiUtils;
import org.joinmastodon.android.ui.views.ProgressBarButton;
import androidx.annotation.DrawableRes;
import androidx.annotation.NonNull;
import androidx.annotation.StringRes;
import me.grishka.appkit.utils.V;
import me.grishka.appkit.views.BottomSheet;
public abstract class AccountRestrictionConfirmationSheet extends BottomSheet{
private LinearLayout contentWrap;
protected Button cancelBtn;
protected ProgressBarButton confirmBtn, secondaryBtn;
protected TextView titleView, subtitleView;
protected ImageView icon;
protected boolean loading;
public AccountRestrictionConfirmationSheet(@NonNull Context context, Account user, ConfirmCallback confirmCallback){
super(context);
View content=context.getSystemService(LayoutInflater.class).inflate(R.layout.sheet_restrict_account, null);
setContentView(content);
setNavigationBarBackground(new ColorDrawable(UiUtils.alphaBlendColors(UiUtils.getThemeColor(context, R.attr.colorM3Surface),
UiUtils.getThemeColor(context, R.attr.colorM3Primary), 0.05f)), !UiUtils.isDarkTheme());
contentWrap=findViewById(R.id.content_wrap);
titleView=findViewById(R.id.title);
subtitleView=findViewById(R.id.text);
cancelBtn=findViewById(R.id.btn_cancel);
confirmBtn=findViewById(R.id.btn_confirm);
secondaryBtn=findViewById(R.id.btn_secondary);
icon=findViewById(R.id.icon);
contentWrap.setDividerDrawable(new EmptyDrawable(1, V.dp(8)));
contentWrap.setShowDividers(LinearLayout.SHOW_DIVIDER_MIDDLE);
confirmBtn.setOnClickListener(v->{
if(loading)
return;
loading=true;
confirmBtn.setProgressBarVisible(true);
confirmCallback.onConfirmed(this::dismiss, ()->{
confirmBtn.setProgressBarVisible(false);
loading=false;
});
});
cancelBtn.setOnClickListener(v->{
if(!loading)
dismiss();
});
}
protected void addRow(@DrawableRes int icon, CharSequence text){
TextView tv=new TextView(getContext());
tv.setTextAppearance(R.style.m3_body_large);
tv.setTextColor(UiUtils.getThemeColor(getContext(), R.attr.colorM3OnSurfaceVariant));
tv.setCompoundDrawableTintList(ColorStateList.valueOf(UiUtils.getThemeColor(getContext(), R.attr.colorM3Primary)));
tv.setGravity(Gravity.START | Gravity.CENTER_VERTICAL);
tv.setText(text);
InsetDrawable drawable=new InsetDrawable(getContext().getResources().getDrawable(icon, getContext().getTheme()), V.dp(8));
drawable.setBounds(0, 0, V.dp(40), V.dp(40));
tv.setCompoundDrawablesRelative(drawable, null, null, null);
tv.setCompoundDrawablePadding(V.dp(16));
contentWrap.addView(tv, new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT));
}
protected void addRow(@DrawableRes int icon, @StringRes int text){
addRow(icon, getContext().getString(text));
}
public interface ConfirmCallback{
void onConfirmed(Runnable onSuccess, Runnable onError);
}
}

View File

@@ -1,24 +0,0 @@
package org.joinmastodon.android.ui.sheets;
import android.content.Context;
import android.view.View;
import org.joinmastodon.android.R;
import org.joinmastodon.android.model.Account;
import androidx.annotation.NonNull;
public class BlockAccountConfirmationSheet extends AccountRestrictionConfirmationSheet{
public BlockAccountConfirmationSheet(@NonNull Context context, Account user, ConfirmCallback confirmCallback){
super(context, user, confirmCallback);
titleView.setText(R.string.block_user_confirm_title);
confirmBtn.setText(R.string.do_block);
secondaryBtn.setVisibility(View.GONE);
icon.setImageResource(R.drawable.ic_block_24px);
subtitleView.setText(user.getDisplayUsername());
addRow(R.drawable.ic_campaign_24px, R.string.user_can_see_blocked);
addRow(R.drawable.ic_visibility_off_24px, R.string.user_cant_see_each_other_posts);
addRow(R.drawable.ic_alternate_email_24px, R.string.you_wont_see_user_mentions);
addRow(R.drawable.ic_reply_24px, R.string.user_cant_mention_or_follow_you);
}
}

View File

@@ -1,36 +0,0 @@
package org.joinmastodon.android.ui.sheets;
import android.content.Context;
import android.view.View;
import org.joinmastodon.android.R;
import org.joinmastodon.android.model.Account;
import androidx.annotation.NonNull;
public class BlockDomainConfirmationSheet extends AccountRestrictionConfirmationSheet{
public BlockDomainConfirmationSheet(@NonNull Context context, Account user, ConfirmCallback confirmCallback, ConfirmCallback blockUserConfirmCallback){
super(context, user, confirmCallback);
titleView.setText(R.string.block_domain_confirm_title);
confirmBtn.setText(R.string.do_block_server);
secondaryBtn.setText(context.getString(R.string.block_user_x_instead, user.getDisplayUsername()));
icon.setImageResource(R.drawable.ic_domain_disabled_24px);
subtitleView.setText(user.getDomain());
addRow(R.drawable.ic_campaign_24px, R.string.users_cant_see_blocked);
addRow(R.drawable.ic_visibility_off_24px, R.string.you_wont_see_server_posts);
addRow(R.drawable.ic_person_remove_24px, R.string.server_followers_will_be_removed);
addRow(R.drawable.ic_reply_24px, R.string.server_cant_mention_or_follow_you);
addRow(R.drawable.ic_history_24px, R.string.server_can_interact_with_older);
secondaryBtn.setOnClickListener(v->{
if(loading)
return;
loading=true;
secondaryBtn.setProgressBarVisible(true);
blockUserConfirmCallback.onConfirmed(this::dismiss, ()->{
secondaryBtn.setProgressBarVisible(false);
loading=false;
});
});
}
}

View File

@@ -1,101 +0,0 @@
package org.joinmastodon.android.ui.sheets;
import android.content.ClipData;
import android.content.ClipboardManager;
import android.content.Context;
import android.graphics.drawable.ColorDrawable;
import android.text.SpannableStringBuilder;
import android.text.Spanned;
import android.text.TextUtils;
import android.view.LayoutInflater;
import android.view.View;
import android.widget.TextView;
import org.joinmastodon.android.R;
import org.joinmastodon.android.api.session.AccountSessionManager;
import org.joinmastodon.android.model.Account;
import org.joinmastodon.android.ui.M3AlertDialogBuilder;
import org.joinmastodon.android.ui.Snackbar;
import org.joinmastodon.android.ui.text.LinkSpan;
import org.joinmastodon.android.ui.utils.UiUtils;
import org.joinmastodon.android.ui.views.RippleAnimationTextView;
import org.jsoup.Jsoup;
import org.jsoup.nodes.Element;
import org.jsoup.nodes.Node;
import org.jsoup.nodes.TextNode;
import org.jsoup.select.NodeVisitor;
import androidx.annotation.NonNull;
import me.grishka.appkit.views.BottomSheet;
public class DecentralizationExplainerSheet extends BottomSheet{
private final String handleStr;
public DecentralizationExplainerSheet(@NonNull Context context, String accountID, Account account){
super(context);
View content=context.getSystemService(LayoutInflater.class).inflate(R.layout.sheet_decentralization_info, null);
setContentView(content);
setNavigationBarBackground(new ColorDrawable(UiUtils.alphaBlendColors(UiUtils.getThemeColor(context, R.attr.colorM3Surface),
UiUtils.getThemeColor(context, R.attr.colorM3Primary), 0.05f)), !UiUtils.isDarkTheme());
TextView handleTitle=findViewById(R.id.handle_title);
RippleAnimationTextView handle=findViewById(R.id.handle);
TextView usernameExplanation=findViewById(R.id.username_text);
TextView serverExplanation=findViewById(R.id.server_text);
TextView handleExplanation=findViewById(R.id.handle_explanation);
findViewById(R.id.btn_cancel).setOnClickListener(v->dismiss());
String domain=account.getDomain();
if(TextUtils.isEmpty(domain))
domain=AccountSessionManager.get(accountID).domain;
handleStr="@"+account.username+"@"+domain;
boolean isSelf=AccountSessionManager.getInstance().isSelf(accountID, account);
handleTitle.setText(isSelf ? R.string.handle_title_own : R.string.handle_title);
handle.setText(handleStr);
usernameExplanation.setText(isSelf ? R.string.handle_username_explanation_own : R.string.handle_username_explanation);
serverExplanation.setText(isSelf ? R.string.handle_server_explanation_own : R.string.handle_server_explanation);
String explanation=context.getString(isSelf ? R.string.handle_explanation_own : R.string.handle_explanation);
SpannableStringBuilder ssb=new SpannableStringBuilder();
Jsoup.parseBodyFragment(explanation).body().traverse(new NodeVisitor(){
private int spanStart;
@Override
public void head(Node node, int depth){
if(node instanceof TextNode tn){
ssb.append(tn.text());
}else if(node instanceof Element){
spanStart=ssb.length();
}
}
@Override
public void tail(Node node, int depth){
if(node instanceof Element){
ssb.setSpan(new LinkSpan("", DecentralizationExplainerSheet.this::showActivityPubAlert, LinkSpan.Type.CUSTOM, null, null, null), spanStart, ssb.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
}
}
});
handleExplanation.setText(ssb);
findViewById(R.id.handle_wrap).setOnClickListener(v->{
context.getSystemService(ClipboardManager.class).setPrimaryClip(ClipData.newPlainText(null, handleStr));
if(UiUtils.needShowClipboardToast()){
new Snackbar.Builder(context)
.setText(R.string.handle_copied)
.show();
}
});
String _domain=domain;
findViewById(R.id.username_row).setOnClickListener(v->handle.animate(1, account.username.length()+1));
findViewById(R.id.server_row).setOnClickListener(v->handle.animate(handleStr.length()-_domain.length(), handleStr.length()));
}
private void showActivityPubAlert(LinkSpan s){
new M3AlertDialogBuilder(getContext())
.setTitle(R.string.what_is_activitypub_title)
.setMessage(R.string.what_is_activitypub)
.setPositiveButton(R.string.ok, null)
.show();
}
}

View File

@@ -1,24 +0,0 @@
package org.joinmastodon.android.ui.sheets;
import android.content.Context;
import android.view.View;
import org.joinmastodon.android.R;
import org.joinmastodon.android.model.Account;
import androidx.annotation.NonNull;
public class MuteAccountConfirmationSheet extends AccountRestrictionConfirmationSheet{
public MuteAccountConfirmationSheet(@NonNull Context context, Account user, ConfirmCallback confirmCallback){
super(context, user, confirmCallback);
titleView.setText(R.string.mute_user_confirm_title);
confirmBtn.setText(R.string.do_mute);
secondaryBtn.setVisibility(View.GONE);
icon.setImageResource(R.drawable.ic_volume_off_24px);
subtitleView.setText(user.getDisplayUsername());
addRow(R.drawable.ic_campaign_24px, R.string.user_wont_know_muted);
addRow(R.drawable.ic_visibility_off_24px, R.string.user_can_still_see_your_posts);
addRow(R.drawable.ic_alternate_email_24px, R.string.you_wont_see_user_mentions);
addRow(R.drawable.ic_reply_24px, R.string.user_can_mention_and_follow_you);
}
}

View File

@@ -1715,7 +1715,9 @@ public class TabLayout extends HorizontalScrollView implements CustomViewHelper{
child.getLayoutParams().height);
int childWidthMeasureSpec =
MeasureSpec.makeMeasureSpec(getMeasuredWidth(), MeasureSpec.EXACTLY);
MeasureSpec.makeMeasureSpec(
getMeasuredWidth() - getPaddingLeft() - getPaddingRight(),
MeasureSpec.EXACTLY);
child.measure(childWidthMeasureSpec, childHeightMeasureSpec);
}
}

View File

@@ -1,9 +1,5 @@
package org.joinmastodon.android.ui.text;
import android.content.ClipData;
import android.content.ClipboardManager;
import android.content.Context;
import android.content.res.Resources;
import android.graphics.Canvas;
import android.graphics.CornerPathEffect;
import android.graphics.Paint;
@@ -15,15 +11,14 @@ import android.view.GestureDetector;
import android.view.MotionEvent;
import android.view.SoundEffectConstants;
import android.widget.TextView;
import android.widget.Toast;
import org.joinmastodon.android.R;
import org.joinmastodon.android.ui.utils.UiUtils;
import androidx.annotation.NonNull;
import me.grishka.appkit.utils.CustomViewHelper;
public class ClickableLinksDelegate implements CustomViewHelper{
import org.joinmastodon.android.ui.utils.UiUtils;
import me.grishka.appkit.utils.V;
public class ClickableLinksDelegate {
private final Paint hlPaint;
private Path hlPath;
@@ -36,9 +31,9 @@ public class ClickableLinksDelegate implements CustomViewHelper{
this.view=view;
hlPaint=new Paint();
hlPaint.setAntiAlias(true);
hlPaint.setPathEffect(new CornerPathEffect(dp(3)));
hlPaint.setPathEffect(new CornerPathEffect(V.dp(3)));
hlPaint.setStyle(Paint.Style.FILL_AND_STROKE);
hlPaint.setStrokeWidth(dp(4));
hlPaint.setStrokeWidth(V.dp(4));
gestureDetector = new GestureDetector(view.getContext(), new LinkGestureListener(), view.getHandler());
}
@@ -69,11 +64,6 @@ public class ClickableLinksDelegate implements CustomViewHelper{
}
}
@Override
public Resources getResources(){
return view.getResources();
}
/**
* GestureListener for spans that represent URLs.
* onDown: on start of touch event, set highlighting
@@ -125,13 +115,8 @@ public class ClickableLinksDelegate implements CustomViewHelper{
@Override
public void onLongPress(@NonNull MotionEvent event) {
//if target is not a link, don't copy
if (selectedSpan == null) return;
if (selectedSpan.getType() != LinkSpan.Type.URL) return;
//copy link text to clipboard
ClipboardManager clipboard = (ClipboardManager) view.getContext().getSystemService(Context.CLIPBOARD_SERVICE);
clipboard.setPrimaryClip(ClipData.newPlainText("", selectedSpan.getLink()));
UiUtils.maybeShowTextCopiedToast(view.getContext());
UiUtils.copyText(view, selectedSpan.getType() == LinkSpan.Type.URL ? selectedSpan.getLink() : selectedSpan.getText());
//reset view
resetAndInvalidate();
}

View File

@@ -0,0 +1,26 @@
package org.joinmastodon.android.ui.text;
import android.text.TextPaint;
import android.text.style.CharacterStyle;
public class DiffRemovedSpan extends CharacterStyle {
private final String text;
private final int color;
public DiffRemovedSpan(String text, int color){
this.text=text;
this.color=color;
}
@Override
public void updateDrawState(TextPaint tp) {
tp.setStrikeThruText(true);
tp.setColor(color);
}
public String getText() {
return text;
}
}

View File

@@ -1,11 +1,22 @@
package org.joinmastodon.android.ui.text;
import android.content.Context;
import android.graphics.Color;
import android.graphics.Typeface;
import android.text.SpannableStringBuilder;
import android.text.Spanned;
import android.text.TextUtils;
import android.text.style.BackgroundColorSpan;
import android.text.style.BulletSpan;
import android.text.style.ForegroundColorSpan;
import android.text.style.LeadingMarginSpan;
import android.text.style.RelativeSizeSpan;
import android.text.style.StrikethroughSpan;
import android.text.style.StyleSpan;
import android.text.style.SubscriptSpan;
import android.text.style.SuperscriptSpan;
import android.text.style.TypefaceSpan;
import android.text.style.UnderlineSpan;
import android.widget.TextView;
import com.twitter.twittertext.Regex;
@@ -26,6 +37,7 @@ import org.jsoup.safety.Safelist;
import org.jsoup.select.NodeVisitor;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.Map;
import java.util.function.Function;
@@ -35,6 +47,8 @@ import java.util.stream.Collectors;
import androidx.annotation.NonNull;
import me.grishka.appkit.utils.V;
public class HtmlParser{
private static final String TAG="HtmlParser";
private static final String VALID_URL_PATTERN_STRING =
@@ -57,6 +71,18 @@ public class HtmlParser{
private HtmlParser(){}
public static SpannableStringBuilder parse(String source, List<Emoji> emojis, List<Mention> mentions, List<Hashtag> tags, String accountID){
return parse(source, emojis, mentions, tags, accountID, null, null);
}
public static SpannableStringBuilder parse(String source, List<Emoji> emojis, List<Mention> mentions, List<Hashtag> tags, String accountID, Context context){
return parse(source, emojis, mentions, tags, accountID, null, context);
}
public static SpannableStringBuilder parse(String source, List<Emoji> emojis, List<Mention> mentions, List<Hashtag> tags, String accountID, Object parentObject){
return parse(source, emojis, mentions, tags, accountID, parentObject, null);
}
/**
* Parse HTML and custom emoji into a spanned string for display.
* Supported tags: <ul>
@@ -69,16 +95,22 @@ public class HtmlParser{
* @param emojis Custom emojis that are present in source as <code>:code:</code>
* @return a spanned string
*/
public static SpannableStringBuilder parse(String source, List<Emoji> emojis, List<Mention> mentions, List<Hashtag> tags, String accountID, Object parentObject){
public static SpannableStringBuilder parse(String source, List<Emoji> emojis, List<Mention> mentions, List<Hashtag> tags, String accountID, Object parentObject, Context context){
class SpanInfo{
public Object span;
public int start;
public Element element;
public boolean more;
public SpanInfo(Object span, int start, Element element){
this(span, start, element, false);
}
public SpanInfo(Object span, int start, Element element, boolean more){
this.span=span;
this.start=start;
this.element=element;
this.more=more;
}
}
@@ -88,6 +120,9 @@ public class HtmlParser{
Map<String, Hashtag> tagsByTag=tags.stream().distinct().collect(Collectors.toMap(t->t.name.toLowerCase(), Function.identity()));
final SpannableStringBuilder ssb=new SpannableStringBuilder();
int colorInsert=UiUtils.getThemeColor(context, R.attr.colorM3Success);
int colorDelete=UiUtils.getThemeColor(context, R.attr.colorM3Error);
Jsoup.parseBodyFragment(source).body().traverse(new NodeVisitor(){
private final ArrayList<SpanInfo> openSpans=new ArrayList<>();
@@ -129,24 +164,62 @@ public class HtmlParser{
openSpans.add(new SpanInfo(new InvisibleSpan(), ssb.length(), el));
}
}
case "li" -> openSpans.add(new SpanInfo(new BulletSpan(V.dp(8)), ssb.length(), el));
case "em", "i" -> openSpans.add(new SpanInfo(new StyleSpan(Typeface.ITALIC), ssb.length(), el));
case "h1", "h2", "h3", "h4", "h5", "h6" -> {
// increase line height above heading (multiplying the margin)
if (node.previousSibling()!=null) ssb.setSpan(new RelativeSizeSpan(2), ssb.length() - 1, ssb.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
if (!node.nodeName().equals("h1")) {
openSpans.add(new SpanInfo(new StyleSpan(Typeface.BOLD), ssb.length(), el));
}
openSpans.add(new SpanInfo(new RelativeSizeSpan(switch(node.nodeName()) {
case "h1" -> 1.5f;
case "h2" -> 1.25f;
case "h3" -> 1.125f;
default -> 1;
}), ssb.length(), el, !node.nodeName().equals("h1")));
}
case "strong", "b" -> openSpans.add(new SpanInfo(new StyleSpan(Typeface.BOLD), ssb.length(), el));
case "u" -> openSpans.add(new SpanInfo(new UnderlineSpan(), ssb.length(), el));
case "s", "del" -> openSpans.add(new SpanInfo(new StrikethroughSpan(), ssb.length(), el));
case "sub", "sup" -> {
openSpans.add(new SpanInfo(node.nodeName().equals("sub") ? new SubscriptSpan() : new SuperscriptSpan(), ssb.length(), el));
openSpans.add(new SpanInfo(new RelativeSizeSpan(0.8f), ssb.length(), el, true));
}
case "code", "pre" -> openSpans.add(new SpanInfo(new TypefaceSpan("monospace"), ssb.length(), el));
case "blockquote" -> openSpans.add(new SpanInfo(new LeadingMarginSpan.Standard(V.dp(10)), ssb.length(), el));
// fake elements for the edit history diff view
case "edit-diff-insert" -> openSpans.add(new SpanInfo(new ForegroundColorSpan(colorInsert), ssb.length(), el));
case "edit-diff-delete" -> openSpans.add(new SpanInfo(new DiffRemovedSpan(el.text(), colorDelete), ssb.length(), el));
}
}
}
final static List<String> blockElements = Arrays.asList("p", "ul", "ol", "blockquote", "h1", "h2", "h3", "h4", "h5", "h6");
@Override
public void tail(@NonNull Node node, int depth){
if(node instanceof Element el){
processOpenSpan(el);
if("span".equals(el.nodeName()) && el.hasClass("ellipsis")){
ssb.append("", new DeleteWhenCopiedSpan(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
}else if("p".equals(el.nodeName())){
if(node.nextSibling()!=null)
ssb.append("\n\n");
}else if(!openSpans.isEmpty()){
SpanInfo si=openSpans.get(openSpans.size()-1);
if(si.element==el){
ssb.setSpan(si.span, si.start, ssb.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
openSpans.remove(openSpans.size()-1);
}
}else if(blockElements.contains(el.nodeName()) && node.nextSibling()!=null){
ssb.append("\n"); // line end
ssb.append("\n", new RelativeSizeSpan(0.65f), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); // margin after block
}
}
}
private void processOpenSpan(Element el) {
if(!openSpans.isEmpty()){
SpanInfo si=openSpans.get(openSpans.size()-1);
if(si.element==el){
ssb.setSpan(si.span, si.start, ssb.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
openSpans.remove(openSpans.size()-1);
if(si.more) processOpenSpan(el);
}
if("li".equals(el.nodeName()) && el.nextSibling()!=null) {
ssb.append('\n');
}
}
}
@@ -157,6 +230,7 @@ public class HtmlParser{
}
public static void parseCustomEmoji(SpannableStringBuilder ssb, List<Emoji> emojis){
if(emojis==null) return;
Map<String, Emoji> emojiByCode =
emojis.stream()
.collect(
@@ -228,6 +302,10 @@ public class HtmlParser{
return sb.toString();
}
public static String text(String html) {
return Jsoup.parse(html).body().wholeText();
}
public static CharSequence parseLinks(String text){
Matcher matcher=URL_PATTERN.matcher(text);
if(!matcher.find()) // Return the original string if there are no URLs
@@ -243,6 +321,7 @@ public class HtmlParser{
}
public static void applyFilterHighlights(Context context, SpannableStringBuilder text, List<FilterResult> filters){
if (filters == null) return;
int fgColor=UiUtils.getThemeColor(context, R.attr.colorM3Error);
int bgColor=UiUtils.getThemeColor(context, R.attr.colorM3ErrorContainer);
for(FilterResult filter:filters){

View File

@@ -1,67 +0,0 @@
package org.joinmastodon.android.ui.text;
import android.content.Context;
import android.graphics.Bitmap;
import android.graphics.Paint;
import android.graphics.drawable.Drawable;
import android.net.Uri;
import android.text.style.ImageSpan;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
public class ImageSpanThatDoesNotBreakShitForNoGoodReason extends ImageSpan{
public ImageSpanThatDoesNotBreakShitForNoGoodReason(@NonNull Bitmap b){
super(b);
}
public ImageSpanThatDoesNotBreakShitForNoGoodReason(@NonNull Bitmap b, int verticalAlignment){
super(b, verticalAlignment);
}
public ImageSpanThatDoesNotBreakShitForNoGoodReason(@NonNull Context context, @NonNull Bitmap bitmap){
super(context, bitmap);
}
public ImageSpanThatDoesNotBreakShitForNoGoodReason(@NonNull Context context, @NonNull Bitmap bitmap, int verticalAlignment){
super(context, bitmap, verticalAlignment);
}
public ImageSpanThatDoesNotBreakShitForNoGoodReason(@NonNull Drawable drawable){
super(drawable);
}
public ImageSpanThatDoesNotBreakShitForNoGoodReason(@NonNull Drawable drawable, int verticalAlignment){
super(drawable, verticalAlignment);
}
public ImageSpanThatDoesNotBreakShitForNoGoodReason(@NonNull Drawable drawable, @NonNull String source){
super(drawable, source);
}
public ImageSpanThatDoesNotBreakShitForNoGoodReason(@NonNull Drawable drawable, @NonNull String source, int verticalAlignment){
super(drawable, source, verticalAlignment);
}
public ImageSpanThatDoesNotBreakShitForNoGoodReason(@NonNull Context context, @NonNull Uri uri){
super(context, uri);
}
public ImageSpanThatDoesNotBreakShitForNoGoodReason(@NonNull Context context, @NonNull Uri uri, int verticalAlignment){
super(context, uri, verticalAlignment);
}
public ImageSpanThatDoesNotBreakShitForNoGoodReason(@NonNull Context context, int resourceId){
super(context, resourceId);
}
public ImageSpanThatDoesNotBreakShitForNoGoodReason(@NonNull Context context, int resourceId, int verticalAlignment){
super(context, resourceId, verticalAlignment);
}
@Override
public int getSize(@NonNull Paint paint, CharSequence text, int start, int end, @Nullable Paint.FontMetricsInt fm){
// Purposefully not touching the font metrics
return getDrawable().getBounds().right;
}
}

View File

@@ -3,7 +3,9 @@ package org.joinmastodon.android.ui.text;
import android.content.Context;
import android.text.TextPaint;
import android.text.style.CharacterStyle;
import android.view.View;
import org.joinmastodon.android.GlobalUserPreferences;
import org.joinmastodon.android.model.Hashtag;
import org.joinmastodon.android.ui.utils.UiUtils;
@@ -33,7 +35,7 @@ public class LinkSpan extends CharacterStyle {
@Override
public void updateDrawState(TextPaint tp) {
tp.setColor(color=tp.linkColor);
tp.setUnderlineText(true);
tp.setUnderlineText(GlobalUserPreferences.underlinedLinks);
}
public void onClick(Context context){
@@ -50,10 +52,21 @@ public class LinkSpan extends CharacterStyle {
}
}
public void onLongClick(View view) {
if(linkObject instanceof Hashtag ht)
UiUtils.copyText(view, ht.name);
else
UiUtils.copyText(view, link);
}
public String getLink(){
return link;
}
public String getText() {
return parentObject.toString();
}
public Type getType(){
return type;
}

View File

@@ -0,0 +1,81 @@
package org.joinmastodon.android.ui.utils;
import static org.joinmastodon.android.GlobalUserPreferences.ThemePreference;
import static org.joinmastodon.android.GlobalUserPreferences.trueBlackTheme;
import static org.joinmastodon.android.api.session.AccountLocalPreferences.ColorPreference.*;
import android.content.Context;
import android.content.res.Resources;
import androidx.annotation.StyleRes;
import org.joinmastodon.android.GlobalUserPreferences;
import org.joinmastodon.android.R;
import org.joinmastodon.android.api.session.AccountLocalPreferences;
import java.util.Map;
public class ColorPalette {
public static final Map<AccountLocalPreferences.ColorPreference, ColorPalette> palettes = Map.of(
MATERIAL3, new ColorPalette(R.style.ColorPalette_Material3)
.dark(R.style.ColorPalette_Material3_Dark, R.style.ColorPalette_Material3_AutoLightDark),
PINK, new ColorPalette(R.style.ColorPalette_Pink),
PURPLE, new ColorPalette(R.style.ColorPalette_Purple),
GREEN, new ColorPalette(R.style.ColorPalette_Green),
BLUE, new ColorPalette(R.style.ColorPalette_Blue),
BROWN, new ColorPalette(R.style.ColorPalette_Brown),
RED, new ColorPalette(R.style.ColorPalette_Red),
YELLOW, new ColorPalette(R.style.ColorPalette_Yellow),
NORD, new ColorPalette(R.style.ColorPalette_Nord),
WHITE, new ColorPalette(R.style.ColorPalette_White)
);
private @StyleRes int base;
private @StyleRes int autoDark;
private @StyleRes int light;
private @StyleRes int dark;
private @StyleRes int black;
private @StyleRes int autoBlack;
public ColorPalette(@StyleRes int baseRes) { base = baseRes; }
public ColorPalette(@StyleRes int lightRes, @StyleRes int darkRes, @StyleRes int autoDarkRes, @StyleRes int blackRes, @StyleRes int autoBlackRes) {
light = lightRes;
dark = darkRes;
autoDark = autoDarkRes;
black = blackRes;
autoBlack = autoBlackRes;
}
public ColorPalette light(@StyleRes int res) { light = res; return this; }
public ColorPalette dark(@StyleRes int res, @StyleRes int auto) { dark = res; autoDark = auto; return this; }
public ColorPalette black(@StyleRes int res, @StyleRes int auto) { dark = res; autoBlack = auto; return this; }
public void apply(Context context) {
apply(context, GlobalUserPreferences.theme);
}
public void apply(Context context, ThemePreference theme) {
if (!((dark != 0 && autoDark != 0) || (black != 0 && autoBlack != 0) || light != 0 || base != 0)) {
throw new IllegalStateException("Invalid color scheme definition");
}
Resources.Theme t = context.getTheme();
t.applyStyle(R.style.ColorPalette_Fallback, true);
if (base != 0) t.applyStyle(base, true);
if (light != 0 && theme.equals(ThemePreference.LIGHT)) {
t.applyStyle(light, true);
} else if (theme.equals(ThemePreference.DARK)) {
t.applyStyle(R.style.ColorPalette_Dark, true);
if (trueBlackTheme) t.applyStyle(R.style.ColorPalette_Dark_TrueBlack, true);
if (dark != 0 && !trueBlackTheme) t.applyStyle(dark, true);
else if (black != 0 && trueBlackTheme) t.applyStyle(black, true);
} else if (theme.equals(ThemePreference.AUTO)) {
t.applyStyle(R.style.ColorPalette_AutoLightDark, true);
if (trueBlackTheme) t.applyStyle(R.style.ColorPalette_AutoLightDark_TrueBlack, true);
if (autoDark != 0 && !trueBlackTheme) t.applyStyle(autoDark, true);
else if (autoBlack != 0 && trueBlackTheme) t.applyStyle(autoBlack, true);
}
}
}

View File

@@ -29,6 +29,16 @@ public class CustomEmojiHelper{
}
}
public void addText(CharSequence text) {
if(!(text instanceof Spanned))
return;
CustomEmojiSpan[] spans=((Spanned) text).getSpans(0, text.length(), CustomEmojiSpan.class);
for(List<CustomEmojiSpan> group:Arrays.stream(spans).collect(Collectors.groupingBy(s->s.emoji)).values()){
this.spans.add(group);
requests.add(group.get(0).createImageLoaderRequest());
}
}
public int getImageCount(){
return requests.size();
}

View File

@@ -10,6 +10,7 @@ import android.widget.TextView;
import org.joinmastodon.android.MastodonApp;
import org.joinmastodon.android.R;
import org.joinmastodon.android.api.session.AccountSessionManager;
import org.joinmastodon.android.model.TimelineDefinition;
import java.util.EnumSet;
@@ -48,17 +49,23 @@ public class DiscoverInfoBannerHelper{
banner=((Activity)list.getContext()).getLayoutInflater().inflate(R.layout.discover_info_banner, list, false);
TextView text=banner.findViewById(R.id.banner_text);
text.setText(switch(type){
case TRENDING_POSTS -> list.getResources().getString(R.string.trending_posts_info_banner);
case TRENDING_LINKS -> list.getResources().getString(R.string.trending_links_info_banner);
case TRENDING_POSTS -> list.getResources().getString(R.string.sk_trending_posts_info_banner);
case TRENDING_LINKS -> list.getResources().getString(R.string.sk_trending_links_info_banner);
case FEDERATED_TIMELINE -> list.getResources().getString(R.string.sk_federated_timeline_info_banner);
case POST_NOTIFICATIONS -> list.getResources().getString(R.string.sk_notify_posts_info_banner);
case BUBBLE_TIMELINE -> list.getResources().getString(R.string.sk_bubble_timeline_info_banner);
case LOCAL_TIMELINE -> list.getResources().getString(R.string.local_timeline_info_banner, AccountSessionManager.get(accountID).domain);
case ACCOUNTS -> list.getResources().getString(R.string.recommended_accounts_info_banner);
});
ImageView icon=banner.findViewById(R.id.icon);
icon.setImageResource(switch(type){
case TRENDING_POSTS -> R.drawable.ic_whatshot_24px;
case TRENDING_LINKS -> R.drawable.ic_feed_24px;
case LOCAL_TIMELINE -> R.drawable.ic_stream_24px;
case ACCOUNTS -> R.drawable.ic_group_add_24px;
case TRENDING_POSTS -> R.drawable.ic_fluent_arrow_trending_24_regular;
case TRENDING_LINKS -> R.drawable.ic_fluent_news_24_regular;
case ACCOUNTS -> R.drawable.ic_fluent_people_add_24_regular;
case LOCAL_TIMELINE -> TimelineDefinition.LOCAL_TIMELINE.getDefaultIcon().iconRes;
case FEDERATED_TIMELINE -> TimelineDefinition.FEDERATED_TIMELINE.getDefaultIcon().iconRes;
case BUBBLE_TIMELINE -> TimelineDefinition.BUBBLE_TIMELINE.getDefaultIcon().iconRes;
case POST_NOTIFICATIONS -> TimelineDefinition.POSTS_TIMELINE.getDefaultIcon().iconRes;
});
adapter.addAdapter(0, bannerAdapter=new SingleViewRecyclerAdapter(banner));
added=true;
@@ -89,6 +96,9 @@ public class DiscoverInfoBannerHelper{
TRENDING_POSTS,
TRENDING_LINKS,
LOCAL_TIMELINE,
ACCOUNTS
FEDERATED_TIMELINE,
POST_NOTIFICATIONS,
ACCOUNTS,
BUBBLE_TIMELINE
}
}
}

View File

@@ -8,8 +8,9 @@ import android.view.View;
import org.joinmastodon.android.R;
import org.joinmastodon.android.fragments.BaseStatusListFragment;
import org.joinmastodon.android.ui.displayitems.NotificationHeaderStatusDisplayItem;
import org.joinmastodon.android.ui.displayitems.StatusDisplayItem;
import org.joinmastodon.android.ui.displayitems.LinkCardStatusDisplayItem;
import org.joinmastodon.android.ui.displayitems.MediaGridStatusDisplayItem;
import java.util.List;
@@ -26,7 +27,7 @@ public class InsetStatusItemDecoration extends RecyclerView.ItemDecoration{
public InsetStatusItemDecoration(BaseStatusListFragment<?> listFragment){
this.listFragment=listFragment;
bgColor=UiUtils.getThemeColor(listFragment.getActivity(), R.attr.colorM3SurfaceVariant);
bgColor=UiUtils.getThemeColor(listFragment.getActivity(), R.attr.colorM3Surface);
borderColor=UiUtils.getThemeColor(listFragment.getActivity(), R.attr.colorM3OutlineVariant);
}
@@ -41,13 +42,17 @@ public class InsetStatusItemDecoration extends RecyclerView.ItemDecoration{
boolean inset=(holder instanceof StatusDisplayItem.Holder<?> sdi) && sdi.getItem().inset;
if(inset){
if(rect.isEmpty()){
float childY=child.getY();
if(pos>0 && displayItems.get(pos-1).getType()==StatusDisplayItem.Type.REBLOG_OR_REPLY_LINE){
childY+=V.dp(8);
if(holder instanceof MediaGridStatusDisplayItem.Holder || holder instanceof LinkCardStatusDisplayItem.Holder){
rect.set(child.getX(), i == 0 && pos > 0 && displayItems.get(pos - 1).inset ? V.dp(-10) : child.getY(), child.getX() + child.getWidth(), child.getY() + child.getHeight() + V.dp(4));
}else {
rect.set(child.getX(), i == 0 && pos > 0 && displayItems.get(pos - 1).inset ? V.dp(-10) : child.getY(), child.getX() + child.getWidth(), child.getY() + child.getHeight());
}
rect.set(child.getX(), i==0 && pos>0 && displayItems.get(pos-1).inset ? V.dp(-10) : childY, child.getX()+child.getWidth(), child.getY()+child.getHeight());
}else{
rect.bottom=Math.max(rect.bottom, child.getY()+child.getHeight());
if(holder instanceof MediaGridStatusDisplayItem.Holder || holder instanceof LinkCardStatusDisplayItem.Holder){
rect.bottom=Math.max(rect.bottom, child.getY()+child.getHeight()) + V.dp(4);
}else {
rect.bottom=Math.max(rect.bottom, child.getY()+child.getHeight());
}
}
}else if(!rect.isEmpty()){
drawInsetBackground(parent, c);
@@ -66,35 +71,40 @@ public class InsetStatusItemDecoration extends RecyclerView.ItemDecoration{
private void drawInsetBackground(RecyclerView list, Canvas c){
paint.setStyle(Paint.Style.FILL);
paint.setColor(bgColor);
rect.left=V.dp(16);
rect.right=list.getWidth()-V.dp(16);
c.drawRoundRect(rect, V.dp(4), V.dp(4), paint);
rect.left=V.dp(12);
rect.right=list.getWidth()-V.dp(12);
rect.intersect(V.dp(4), V.dp(4), V.dp(4), V.dp(-4));
c.drawRoundRect(rect, V.dp(12), V.dp(12), paint);
paint.setStyle(Paint.Style.STROKE);
paint.setStrokeWidth(V.dp(1));
paint.setColor(borderColor);
rect.inset(paint.getStrokeWidth()/2f, paint.getStrokeWidth()/2f);
c.drawRoundRect(rect, V.dp(4), V.dp(4), paint);
c.drawRoundRect(rect, V.dp(12), V.dp(12), paint);
}
@Override
public void getItemOffsets(@NonNull Rect outRect, @NonNull View view, @NonNull RecyclerView parent, @NonNull RecyclerView.State state){
List<StatusDisplayItem> displayItems=listFragment.getDisplayItems();
// List<StatusDisplayItem> displayItems=listFragment.getDisplayItems();
RecyclerView.ViewHolder holder=parent.getChildViewHolder(view);
if(holder instanceof StatusDisplayItem.Holder<?> sdi){
boolean inset=sdi.getItem().inset;
int pos=holder.getAbsoluteAdapterPosition();
// int pos=holder.getAbsoluteAdapterPosition();
if(inset){
boolean topSiblingInset=pos>0 && displayItems.get(pos-1).inset;
boolean bottomSiblingInset=pos<displayItems.size()-1 && displayItems.get(pos+1).inset;
StatusDisplayItem.Type type=sdi.getItem().getType();
if(type==StatusDisplayItem.Type.CARD_LARGE || type==StatusDisplayItem.Type.MEDIA_GRID)
outRect.left=outRect.right=V.dp(16);
else
outRect.left=outRect.right=V.dp(8);
if(!bottomSiblingInset)
outRect.bottom=V.dp(16);
if(!topSiblingInset && pos > 1 && displayItems.get(pos-1) instanceof NotificationHeaderStatusDisplayItem)
outRect.top=V.dp(-8);
// boolean topSiblingInset=pos>0 && displayItems.get(pos-1).inset;
// boolean bottomSiblingInset=pos<displayItems.size()-1 && displayItems.get(pos+1).inset;
// if(holder instanceof MediaGridStatusDisplayItem.Holder || holder instanceof LinkCardStatusDisplayItem.Holder)
int pad=V.dp(16);
// else pad=V.dp(12);
outRect.left=pad;
outRect.right=pad;
// had to comment this out because animations with offsets aren't handled properly.
// can be worked around by manually applying top margins to items
// see InsetDummyStatusDisplayItem#onBind
// if(!topSiblingInset)
// outRect.top=pad;
// if(!bottomSiblingInset)
// outRect.bottom=pad;
}
}
}

View File

@@ -9,6 +9,7 @@ import android.view.View;
import android.widget.ImageView;
import android.widget.TextView;
import org.joinmastodon.android.GlobalUserPreferences;
import org.joinmastodon.android.R;
import org.joinmastodon.android.model.Attachment;
import org.joinmastodon.android.model.Status;
@@ -20,7 +21,7 @@ public class MediaAttachmentViewController{
public final View view;
public final MediaGridStatusDisplayItem.GridItemType type;
public final ImageView photo;
public final View altButton;
public final View altButton, noAltButton, btnsWrap, extraBadge;
public final TextView duration;
public final View playButton;
private BlurhashCrossfadeDrawable crossfadeDrawable=new BlurhashCrossfadeDrawable();
@@ -37,8 +38,11 @@ public class MediaAttachmentViewController{
}, null);
photo=view.findViewById(R.id.photo);
altButton=view.findViewById(R.id.alt_button);
noAltButton=view.findViewById(R.id.no_alt_button);
btnsWrap=view.findViewById(R.id.alt_badges);
duration=view.findViewById(R.id.duration);
playButton=view.findViewById(R.id.play_button);
extraBadge=view.findViewById(R.id.extra_badge);
this.type=type;
this.context=context;
if(playButton!=null){
@@ -57,9 +61,12 @@ public class MediaAttachmentViewController{
crossfadeDrawable.setCrossfadeAlpha(0f);
photo.setImageDrawable(null);
photo.setImageDrawable(crossfadeDrawable);
photo.setContentDescription(TextUtils.isEmpty(attachment.description) ? context.getString(R.string.media_no_description) : attachment.description);
if(altButton!=null){
altButton.setVisibility(TextUtils.isEmpty(attachment.description) ? View.GONE : View.VISIBLE);
boolean hasAltText = !TextUtils.isEmpty(attachment.description);
photo.setContentDescription(!hasAltText ? context.getString(R.string.media_no_description) : attachment.description);
if(btnsWrap!=null){
btnsWrap.setVisibility(View.VISIBLE);
altButton.setVisibility(hasAltText && GlobalUserPreferences.showAltIndicator ? View.VISIBLE : View.GONE);
noAltButton.setVisibility(!hasAltText && GlobalUserPreferences.showNoAltIndicator ? View.VISIBLE : View.GONE);
}
if(type==MediaGridStatusDisplayItem.GridItemType.VIDEO){
duration.setText(UiUtils.formatMediaDuration((int)attachment.getDuration()));

View File

@@ -0,0 +1,56 @@
package org.joinmastodon.android.ui.utils;
import android.content.Context;
import android.graphics.drawable.Drawable;
import android.os.Build;
import android.text.TextUtils;
import android.view.LayoutInflater;
import android.view.View;
import android.widget.ImageView;
import android.widget.TextView;
import org.joinmastodon.android.GlobalUserPreferences;
import org.joinmastodon.android.R;
import org.joinmastodon.android.model.Attachment;
import org.joinmastodon.android.model.Status;
import org.joinmastodon.android.ui.displayitems.MediaGridStatusDisplayItem;
import org.joinmastodon.android.ui.drawables.BlurhashCrossfadeDrawable;
import org.joinmastodon.android.ui.drawables.PlayIconDrawable;
public class PreviewlessMediaAttachmentViewController{
public final View view;
public final MediaGridStatusDisplayItem.GridItemType type;
private final TextView title, domain;
public final View inner;
private final ImageView icon;
private final Context context;
private Status status;
public PreviewlessMediaAttachmentViewController(Context context, MediaGridStatusDisplayItem.GridItemType type){
view=context.getSystemService(LayoutInflater.class).inflate(R.layout.display_item_file, null);
title=view.findViewById(R.id.title);
domain=view.findViewById(R.id.domain);
icon=view.findViewById(R.id.imageView);
inner=view.findViewById(R.id.inner);
this.context=context;
this.type=type;
}
public void bind(Attachment attachment, Status status){
this.status=status;
title.setText(attachment.description != null
? attachment.description
: context.getString(R.string.sk_no_alt_text));
title.setSingleLine(false);
domain.setText(status.sensitive ? context.getString(R.string.sensitive_content_explain) : null);
domain.setVisibility(status.sensitive ? View.VISIBLE : View.GONE);
if(attachment.type == Attachment.Type.IMAGE)
icon.setImageDrawable(context.getDrawable(R.drawable.ic_fluent_image_24_regular));
if(attachment.type == Attachment.Type.VIDEO)
icon.setImageDrawable(context.getDrawable(R.drawable.ic_fluent_video_clip_24_regular));
if(attachment.type == Attachment.Type.GIFV)
icon.setImageDrawable(context.getDrawable(R.drawable.ic_fluent_gif_24_regular));
}
}

View File

@@ -0,0 +1,242 @@
package org.joinmastodon.android.ui.utils;
/*
* Copyright 2016 Ali Muzaffar
* <p/>
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* <p/>
* http://www.apache.org/licenses/LICENSE-2.0
* <p/>
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import android.graphics.Canvas;
import android.graphics.ColorFilter;
import android.graphics.Paint;
import android.graphics.PixelFormat;
import android.graphics.Rect;
import android.graphics.drawable.Drawable;
import android.text.Editable;
import android.text.TextWatcher;
import android.widget.TextView;
import java.lang.ref.WeakReference;
public class TextDrawable extends Drawable implements TextWatcher {
private WeakReference<TextView> ref;
private String mText;
private Paint mPaint;
private Rect mHeightBounds;
private boolean mBindToViewPaint = false;
private float mPrevTextSize = 0;
private boolean mInitFitText = false;
private boolean mFitTextEnabled = false;
/**
* Create a TextDrawable using the given paint object and string
*
* @param paint
* @param s
*/
public TextDrawable(Paint paint, String s) {
mText = s;
mPaint = new Paint(paint);
mHeightBounds = new Rect();
init();
}
/**
* Create a TextDrawable. This uses the given TextView to initialize paint and has initial text
* that will be drawn. Initial text can also be useful for reserving space that may otherwise
* not be available when setting compound drawables.
*
* @param tv The TextView / EditText using to initialize this drawable
* @param initialText Optional initial text to display
* @param bindToViewsText Should this drawable mirror the text in the TextView
* @param bindToViewsPaint Should this drawable mirror changes to Paint in the TextView, like textColor, typeface, alpha etc.
* Note, this will override any changes made using setColorFilter or setAlpha.
*/
public TextDrawable(TextView tv, String initialText, boolean bindToViewsText, boolean bindToViewsPaint) {
this(tv.getPaint(), initialText);
ref = new WeakReference<>(tv);
if (bindToViewsText || bindToViewsPaint) {
if (bindToViewsText) {
tv.addTextChangedListener(this);
}
mBindToViewPaint = bindToViewsPaint;
}
}
/**
* Create a TextDrawable. This uses the given TextView to initialize paint and the text that
* will be drawn.
*
* @param tv The TextView / EditText using to initialize this drawable
* @param bindToViewsText Should this drawable mirror the text in the TextView
* @param bindToViewsPaint Should this drawable mirror changes to Paint in the TextView, like textColor, typeface, alpha etc.
* Note, this will override any changes made using setColorFilter or setAlpha.
*/
public TextDrawable(TextView tv, boolean bindToViewsText, boolean bindToViewsPaint) {
this(tv, tv.getText().toString(), bindToViewsText, bindToViewsPaint);
}
/**
* Use the provided TextView/EditText to initialize the drawable.
* The Drawable will copy the Text and the Paint properties, however it will from that
* point on be independant of the TextView.
*
* @param tv a TextView or EditText or any of their children.
*/
public TextDrawable(TextView tv) {
this(tv, false, false);
}
/**
* Use the provided TextView/EditText to initialize the drawable.
* The Drawable will copy the Paint properties, and use the provided text to initialise itself.
*
* @param tv a TextView or EditText or any of their children.
* @param s The String to draw
*/
public TextDrawable(TextView tv, String s) {
this(tv, s, false, false);
}
@Override
public void draw(Canvas canvas) {
if (mBindToViewPaint && ref.get() != null) {
Paint p = ref.get().getPaint();
canvas.drawText(mText, 0, getBounds().height(), p);
} else {
if (mInitFitText) {
fitTextAndInit();
}
canvas.drawText(mText, 0, getBounds().height(), mPaint);
}
}
@Override
public void setAlpha(int alpha) {
mPaint.setAlpha(alpha);
}
@Override
public void setColorFilter(ColorFilter colorFilter) {
mPaint.setColorFilter(colorFilter);
}
@Override
public int getOpacity() {
int alpha = mPaint.getAlpha();
if (alpha == 0) {
return PixelFormat.TRANSPARENT;
} else if (alpha == 255) {
return PixelFormat.OPAQUE;
} else {
return PixelFormat.TRANSLUCENT;
}
}
private void init() {
Rect bounds = getBounds();
//We want to use some character to determine the max height of the text.
//Otherwise if we draw something like "..." they will appear centered
//Here I'm just going to use the entire alphabet to determine max height.
mPaint.getTextBounds("1234567890ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz", 0, 1, mHeightBounds);
//This doesn't account for leading or training white spaces.
//mPaint.getTextBounds(mText, 0, mText.length(), bounds);
float width = mPaint.measureText(mText);
bounds.top = mHeightBounds.top;
bounds.bottom = mHeightBounds.bottom;
bounds.right = (int) width;
bounds.left = 0;
setBounds(bounds);
}
public void setPaint(Paint paint) {
mPaint = new Paint(paint);
//Since this can change the font used, we need to recalculate bounds.
if (mFitTextEnabled && !mInitFitText) {
fitTextAndInit();
} else {
init();
}
invalidateSelf();
}
public Paint getPaint() {
return mPaint;
}
public void setText(String text) {
mText = text;
//Since this can change the bounds of the text, we need to recalculate.
if (mFitTextEnabled && !mInitFitText) {
fitTextAndInit();
} else {
init();
}
invalidateSelf();
}
public String getText() {
return mText;
}
@Override
public void beforeTextChanged(CharSequence s, int start, int count, int after) {
}
@Override
public void onTextChanged(CharSequence s, int start, int before, int count) {
}
@Override
public void afterTextChanged(Editable s) {
setText(s.toString());
}
/**
* Make the TextDrawable match the width of the View it's associated with.
* <p/>
* Note: While this option will not work if bindToViewPaint is true.
*
* @param fitText
*/
public void setFillText(boolean fitText) {
mFitTextEnabled = fitText;
if (fitText) {
mPrevTextSize = mPaint.getTextSize();
if (ref.get() != null) {
if (ref.get().getWidth() > 0) {
fitTextAndInit();
} else {
mInitFitText = true;
}
}
} else {
if (mPrevTextSize > 0) {
mPaint.setTextSize(mPrevTextSize);
}
init();
}
}
private void fitTextAndInit() {
float fitWidth = ref.get().getWidth();
float textWidth = mPaint.measureText(mText);
float multi = fitWidth / textWidth;
mPaint.setTextSize(mPaint.getTextSize() * multi);
mInitFitText = false;
init();
}
}

View File

@@ -83,7 +83,7 @@ public class ComposeAutocompleteViewController{
list=new UsableRecyclerView(activity);
list.setLayoutManager(new LinearLayoutManager(activity, LinearLayoutManager.HORIZONTAL, false));
list.setItemAnimator(new BetterItemAnimator());
list.setPadding(V.dp(16), V.dp(12), V.dp(16), V.dp(12));
list.setPadding(V.dp(16), V.dp(4), V.dp(16), V.dp(4));
list.setClipToPadding(false);
list.setSelector(null);
list.addItemDecoration(new RecyclerView.ItemDecoration(){
@@ -162,7 +162,7 @@ public class ComposeAutocompleteViewController{
usersMergeAdapter.addAdapter(usersAdapter);
}
emptyButton.setText(R.string.compose_autocomplete_users_empty);
emptyButton.setDrawableStartTinted(R.drawable.ic_search_20px);
emptyButton.setDrawableStartTinted(R.drawable.ic_fluent_search_20_regular);
yield usersMergeAdapter;
}
case EMOJIS -> {
@@ -173,7 +173,7 @@ public class ComposeAutocompleteViewController{
emojisMergeAdapter.addAdapter(emojisAdapter);
}
emptyButton.setText(R.string.compose_autocomplete_emoji_empty);
emptyButton.setDrawableStartTinted(R.drawable.ic_mood_20px);
emptyButton.setDrawableStartTinted(R.drawable.ic_fluent_emoji_20_regular);
yield emojisMergeAdapter;
}
case HASHTAGS -> {

View File

@@ -15,15 +15,19 @@ import android.widget.CheckedTextView;
import android.widget.RadioButton;
import android.widget.TextView;
import org.joinmastodon.android.GlobalUserPreferences;
import org.joinmastodon.android.R;
import org.joinmastodon.android.api.MastodonAPIController;
import org.joinmastodon.android.api.session.AccountLocalPreferences;
import org.joinmastodon.android.api.session.AccountSession;
import org.joinmastodon.android.api.session.AccountSessionManager;
import org.joinmastodon.android.ui.DividerItemDecoration;
import org.joinmastodon.android.ui.utils.UiUtils;
import org.joinmastodon.android.ui.views.CheckableLinearLayout;
import org.joinmastodon.android.utils.MastodonLanguage;
import org.parceler.Parcel;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Comparator;
import java.util.List;
import java.util.Locale;
@@ -45,38 +49,39 @@ public class ComposeLanguageAlertViewController{
private List<LocaleInfo> allLocales;
private List<SpecialLocaleInfo> specialLocales=new ArrayList<>();
private int selectedIndex=0;
private Locale selectedLocale;
private MastodonLanguage selectedLocale;
private String selectedEncoding;
private MastodonLanguage.LanguageResolver resolver;
public ComposeLanguageAlertViewController(Context context, String preferred, SelectedOption previouslySelected, String postText){
public ComposeLanguageAlertViewController(Context context, String preferred, SelectedOption previouslySelected, String postText, MastodonLanguage.LanguageResolver resolver){
this(context, preferred, previouslySelected, postText, resolver, null);
}
public ComposeLanguageAlertViewController(Context context, String preferred, SelectedOption previouslySelected, String postText, MastodonLanguage.LanguageResolver resolver, AccountSession session){
this.context=context;
this.resolver=resolver;
allLocales=Arrays.stream(Locale.getAvailableLocales())
.map(Locale::getLanguage)
.distinct()
.map(code->{
Locale l=Locale.forLanguageTag(code);
String name=l.getDisplayLanguage(Locale.getDefault());
return new LocaleInfo(l, capitalizeLanguageName(name));
})
allLocales=MastodonLanguage.allLanguages.stream()
.map(l -> new LocaleInfo(l, l.getDisplayName(context)))
.sorted(Comparator.comparing(a->a.displayName))
.collect(Collectors.toList());
if(!TextUtils.isEmpty(preferred)){
Locale l=Locale.forLanguageTag(preferred);
SpecialLocaleInfo pref=new SpecialLocaleInfo();
pref.locale=l;
pref.displayName=capitalizeLanguageName(l.getDisplayLanguage(Locale.getDefault()));
pref.title=context.getString(R.string.language_default);
specialLocales.add(pref);
MastodonLanguage lang=resolver.fromOrFallback(preferred);
specialLocales.add(new SpecialLocaleInfo(
lang,
lang.getDisplayName(context),
context.getString(R.string.language_default)
));
}
Locale def=Locale.forLanguageTag(Locale.getDefault().getLanguage());
if(!def.getLanguage().equals(preferred)){
SpecialLocaleInfo d=new SpecialLocaleInfo();
d.locale=def;
d.displayName=capitalizeLanguageName(def.getDisplayName());
d.title=context.getString(R.string.language_system);
specialLocales.add(d);
if(!Locale.getDefault().getLanguage().equals(preferred)){
MastodonLanguage lang=resolver.getDefault();
specialLocales.add(new SpecialLocaleInfo(
lang,
lang.getDisplayName(context),
context.getString(R.string.language_system)
));
}
if(Build.VERSION.SDK_INT>=29 && !TextUtils.isEmpty(postText)){
@@ -87,11 +92,25 @@ public class ComposeLanguageAlertViewController{
detectLanguage(detected, postText);
}
AccountLocalPreferences lp=session==null ? null : session.getLocalPreferences();
if(lp!=null){
for(String tag : lp.recentLanguages){
if(specialLocales.stream().anyMatch(l->l.language!=null && l.language.languageTag!=null
&& l.language.languageTag.equals(tag))) continue;
resolver.from(tag).ifPresent(lang->specialLocales.add(new SpecialLocaleInfo(
lang, lang.getDisplayName(context), null
)));
}
if(lp.bottomEncoding) {
specialLocales.add(new SpecialLocaleInfo(null, "\uD83E\uDD7A\uD83D\uDC49\uD83D\uDC48", "bottom"));
}
}
if(previouslySelected!=null){
if(previouslySelected.index!=-1 && ((previouslySelected.index<specialLocales.size() && Objects.equals(previouslySelected.locale, specialLocales.get(previouslySelected.index).locale)) ||
(previouslySelected.index<specialLocales.size()+allLocales.size() && previouslySelected.index>-1 && Objects.equals(previouslySelected.locale, allLocales.get(previouslySelected.index-specialLocales.size()).locale)))){
if(previouslySelected.index!=-1 && ((previouslySelected.index<specialLocales.size() && Objects.equals(previouslySelected.language, specialLocales.get(previouslySelected.index).language)) ||
(previouslySelected.index<specialLocales.size()+allLocales.size() && Objects.equals(previouslySelected.language, allLocales.get(previouslySelected.index-specialLocales.size()).language)))){
selectedIndex=previouslySelected.index;
selectedLocale=previouslySelected.locale;
selectedLocale=previouslySelected.language;
}else{
int i=0;
boolean found=false;
@@ -106,8 +125,8 @@ public class ComposeLanguageAlertViewController{
}
if(!found){
for(LocaleInfo li:allLocales){
if(li.locale.equals(previouslySelected.locale)){
selectedLocale=li.locale;
if(li.language.equals(previouslySelected.language)){
selectedLocale=li.language;
selectedIndex=i;
break;
}
@@ -116,7 +135,7 @@ public class ComposeLanguageAlertViewController{
}
}
}else{
selectedLocale=specialLocales.get(0).locale;
selectedLocale=specialLocales.get(0).language;
}
list=new UsableRecyclerView(context);
@@ -126,12 +145,12 @@ public class ComposeLanguageAlertViewController{
list.setAdapter(adapter);
list.setLayoutManager(new LinearLayoutManager(context));
list.addItemDecoration(new DividerItemDecoration(context, R.attr.colorM3OutlineVariant, 1, 16, 16, vh->vh.getAbsoluteAdapterPosition()==specialLocales.size()-1));
list.addItemDecoration(new DividerItemDecoration(context, R.attr.colorM3Outline, 1, 0, 0, vh->vh.getAbsoluteAdapterPosition()==specialLocales.size()-1));
list.addItemDecoration(new RecyclerView.ItemDecoration(){
private Paint paint=new Paint();
{
paint.setColor(UiUtils.getThemeColor(context, R.attr.colorM3OutlineVariant));
paint.setColor(UiUtils.getThemeColor(context, R.attr.colorM3Outline));
paint.setStyle(Paint.Style.STROKE);
paint.setStrokeWidth(V.dp(1));
}
@@ -173,9 +192,9 @@ public class ComposeLanguageAlertViewController{
if(lang.getLocaleHypothesisCount()==0 || lang.getConfidenceScore(lang.getLocale(0))<0.75f){
info.displayName=context.getString(R.string.language_cant_detect);
}else{
Locale locale=lang.getLocale(0).toLocale();
info.locale=locale;
info.displayName=capitalizeLanguageName(locale.getDisplayName(Locale.getDefault()));
MastodonLanguage language = resolver.fromOrFallback(lang.getLocale(0).toLanguageTag());
info.language=language;
info.displayName=language.getDisplayName(context);
info.title=context.getString(R.string.language_detected);
info.enabled=true;
if(holder!=null)
@@ -197,7 +216,7 @@ public class ComposeLanguageAlertViewController{
}
public SelectedOption getSelectedOption(){
return new SelectedOption(selectedIndex, selectedLocale);
return new SelectedOption(selectedIndex, selectedLocale, selectedEncoding);
}
private void selectItem(int index){
@@ -256,7 +275,8 @@ public class ComposeLanguageAlertViewController{
@Override
public void onClick(){
selectItem(getAbsoluteAdapterPosition());
selectedLocale=item.locale;
selectedLocale=item.language;
selectedEncoding=null;
}
}
@@ -313,7 +333,8 @@ public class ComposeLanguageAlertViewController{
@Override
public void onClick(){
selectItem(getAbsoluteAdapterPosition());
selectedLocale=item.locale;
selectedLocale=item.language;
selectedEncoding=item.title != null && item.title.equals("bottom") ? "bottom" : null;
}
@Override
@@ -323,32 +344,50 @@ public class ComposeLanguageAlertViewController{
}
private static class LocaleInfo{
public final Locale locale;
public final MastodonLanguage language;
public final String displayName;
private LocaleInfo(Locale locale, String displayName){
this.locale=locale;
private LocaleInfo(MastodonLanguage language, String displayName){
this.language=language;
this.displayName=displayName;
}
}
private static class SpecialLocaleInfo{
public Locale locale;
public SpecialLocaleInfo() {}
public SpecialLocaleInfo(MastodonLanguage lang, String displayName, String title) {
this.language = lang;
this.displayName = displayName;
this.title = title;
}
public MastodonLanguage language;
public String displayName;
public String title;
public boolean enabled=true;
}
@Parcel
public static class SelectedOption{
public static class SelectedOption {
public int index;
public Locale locale;
public MastodonLanguage language;
public String encoding;
public SelectedOption(){}
public SelectedOption(int index, Locale locale){
this.index=index;
this.locale=locale;
public SelectedOption(int index, MastodonLanguage language, String encoding) {
this.index = index;
this.language = language;
this.encoding = encoding;
}
public SelectedOption(int index, MastodonLanguage language) {
this(index, language, null);
}
public SelectedOption(MastodonLanguage language) {
this(-1, language, null);
}
}
}

View File

@@ -4,11 +4,12 @@ import android.animation.Animator;
import android.animation.AnimatorListenerAdapter;
import android.animation.AnimatorSet;
import android.animation.ObjectAnimator;
import android.animation.ValueAnimator;
import android.content.Context;
import android.database.Cursor;
import android.graphics.Bitmap;
import android.graphics.drawable.ColorDrawable;
import android.graphics.drawable.Drawable;
import android.graphics.drawable.GradientDrawable;
import android.media.MediaMetadataRetriever;
import android.net.Uri;
import android.os.Build;
@@ -19,6 +20,7 @@ import android.text.TextUtils;
import android.util.Log;
import android.view.View;
import android.view.ViewGroup;
import android.view.ViewOutlineProvider;
import android.widget.HorizontalScrollView;
import android.widget.ImageButton;
import android.widget.ImageView;
@@ -27,8 +29,8 @@ import android.widget.ProgressBar;
import android.widget.TextView;
import android.widget.Toast;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.DrawableRes;
import androidx.annotation.StringRes;
import org.joinmastodon.android.MastodonApp;
import org.joinmastodon.android.R;
@@ -51,11 +53,8 @@ import org.parceler.Parcel;
import org.parceler.Parcels;
import java.util.ArrayList;
import java.util.Collection;
import java.util.HashMap;
import java.util.Iterator;
import java.util.List;
import java.util.ListIterator;
import java.util.Locale;
import java.util.Objects;
import java.util.function.Consumer;
@@ -174,6 +173,7 @@ public class ComposeMediaViewController{
}
fragment.updatePublishButtonState();
fragment.updateMediaPollStates();
fragment.updateSensitive();
return true;
}
@@ -203,6 +203,15 @@ public class ComposeMediaViewController{
}
}
private void updateButton(ImageButton btn, @DrawableRes int drawableId, @StringRes int labelId){
btn.setImageResource(drawableId);
String label=fragment.getContext().getString(labelId);
btn.setContentDescription(label);
if(Build.VERSION.SDK_INT >= Build.VERSION_CODES.O){
btn.setTooltipText(label);
}
}
private View createMediaAttachmentView(DraftMediaAttachment draft){
View thumb=fragment.getActivity().getLayoutInflater().inflate(R.layout.compose_media_thumb, attachmentsView, false);
ImageView img=thumb.findViewById(R.id.thumb);
@@ -230,12 +239,11 @@ public class ComposeMediaViewController{
draft.removeButton.setOnClickListener(this::onRemoveMediaAttachmentClick);
draft.editButton.setTag(draft);
thumb.setOutlineProvider(OutlineProviders.roundedRect(12));
thumb.setOutlineProvider(ViewOutlineProvider.BACKGROUND);
thumb.setClipToOutline(true);
img.setOutlineProvider(OutlineProviders.roundedRect(12));
img.setClipToOutline(true);
thumb.setBackgroundColor(UiUtils.getThemeColor(fragment.getActivity(), R.attr.colorM3Surface));
thumb.setOnLongClickListener(v->{
if(!v.hasTransientState() && attachments.size()>1){
attachmentsView.startDragging(v);
@@ -265,11 +273,11 @@ public class ComposeMediaViewController{
draft.subtitleView.setText(subtitleRes);
}
draft.titleView.setText(fragment.getString(R.string.attachment_x_percent_uploaded, 0));
draft.removeButton.setImageResource(R.drawable.ic_baseline_close_24);
updateButton(draft.removeButton, R.drawable.ic_fluent_dismiss_24_regular, R.string.delete);
if(draft.state==AttachmentUploadState.ERROR){
draft.titleView.setText(R.string.upload_failed);
draft.editButton.setImageResource(R.drawable.ic_restart_alt_24px);
updateButton(draft.removeButton, R.drawable.ic_fluent_arrow_counterclockwise_24_regular, R.string.retry);
draft.editButton.setOnClickListener(this::onRetryOrCancelMediaUploadClick);
draft.progressBar.setVisibility(View.GONE);
draft.setUseErrorColors(true);
@@ -279,7 +287,7 @@ public class ComposeMediaViewController{
draft.editButton.setOnClickListener(this::onEditMediaDescriptionClick);
}else{
draft.editButton.setVisibility(View.GONE);
draft.removeButton.setImageResource(R.drawable.ic_baseline_close_24);
updateButton(draft.removeButton, R.drawable.ic_fluent_dismiss_24_regular, R.string.delete);
if(draft.state==AttachmentUploadState.PROCESSING){
draft.titleView.setText(R.string.upload_processing);
}else{
@@ -373,7 +381,7 @@ public class ComposeMediaViewController{
// attachment.retryButton.setContentDescription(fragment.getString(R.string.retry_upload));
V.setVisibilityAnimated(attachment.editButton, View.VISIBLE);
attachment.editButton.setImageResource(R.drawable.ic_restart_alt_24px);
updateButton(attachment.editButton, R.drawable.ic_fluent_arrow_counterclockwise_24_regular, R.string.retry);
attachment.editButton.setOnClickListener(ComposeMediaViewController.this::onRetryOrCancelMediaUploadClick);
attachment.setUseErrorColors(true);
V.setVisibilityAnimated(attachment.progressBar, View.GONE);
@@ -402,6 +410,7 @@ public class ComposeMediaViewController{
}
fragment.updatePublishButtonState();
fragment.updateMediaPollStates();
fragment.updateSensitive();
}
private void onRetryOrCancelMediaUploadClick(View v){
@@ -476,8 +485,8 @@ public class ComposeMediaViewController{
throw new IllegalStateException("Unexpected state "+attachment.state);
attachment.uploadRequest=null;
attachment.state=AttachmentUploadState.DONE;
attachment.editButton.setImageResource(R.drawable.ic_edit_24px);
attachment.removeButton.setImageResource(R.drawable.ic_delete_24px);
updateButton(attachment.editButton, R.drawable.ic_fluent_edit_24_regular, R.string.sk_edit_alt_text);
updateButton(attachment.removeButton, R.drawable.ic_fluent_dismiss_24_regular, R.string.delete);
attachment.editButton.setOnClickListener(this::onEditMediaDescriptionClick);
V.setVisibilityAnimated(attachment.progressBar, View.GONE);
V.setVisibilityAnimated(attachment.editButton, View.VISIBLE);
@@ -504,6 +513,14 @@ public class ComposeMediaViewController{
return false;
}
public boolean areAnyAttachmentsNotDone() {
for(DraftMediaAttachment att:attachments){
if(att.state!=AttachmentUploadState.DONE)
return true;
}
return false;
}
private void onEditMediaDescriptionClick(View v){
DraftMediaAttachment att=(DraftMediaAttachment) v.getTag();
if(att.serverAttachment==null)
@@ -698,18 +715,21 @@ public class ComposeMediaViewController{
if(errorTransitionAnimator!=null)
errorTransitionAnimator.cancel();
AnimatorSet set=new AnimatorSet();
int color1, color2, color3;
int defaultBg=UiUtils.getThemeColor(view.getContext(), R.attr.colorM3Surface);
int errorBg=UiUtils.getThemeColor(view.getContext(), R.attr.colorM3ErrorContainer);
int color2, color3;
if(use){
color1=UiUtils.getThemeColor(view.getContext(), R.attr.colorM3ErrorContainer);
color2=UiUtils.getThemeColor(view.getContext(), R.attr.colorM3Error);
color3=UiUtils.getThemeColor(view.getContext(), R.attr.colorM3OnErrorContainer);
}else{
color1=UiUtils.getThemeColor(view.getContext(), R.attr.colorM3Surface);
color2=UiUtils.getThemeColor(view.getContext(), R.attr.colorM3OnSurface);
color3=UiUtils.getThemeColor(view.getContext(), R.attr.colorM3OnSurfaceVariant);
}
GradientDrawable bg=(GradientDrawable) view.getBackground().mutate();
ValueAnimator bgAnim=ValueAnimator.ofArgb(use ? defaultBg : errorBg, use ? errorBg : defaultBg);
bgAnim.addUpdateListener(anim->bg.setColor((Integer) anim.getAnimatedValue()));
set.playTogether(
ObjectAnimator.ofArgb(view, "backgroundColor", ((ColorDrawable)view.getBackground()).getColor(), color1),
bgAnim,
ObjectAnimator.ofArgb(titleView, "textColor", titleView.getCurrentTextColor(), color2),
ObjectAnimator.ofArgb(subtitleView, "textColor", subtitleView.getCurrentTextColor(), color3),
ObjectAnimator.ofArgb(removeButton.getDrawable(), "tint", subtitleView.getCurrentTextColor(), color3)

View File

@@ -42,6 +42,7 @@ public class ComposePollViewController{
30*60,
3600,
6*3600,
12*3600,
24*3600,
3*24*3600,
7*24*3600,
@@ -74,10 +75,17 @@ public class ComposePollViewController{
pollWrap=view.findViewById(R.id.poll_wrap);
Instance instance=fragment.instance;
if(instance!=null && instance.configuration!=null && instance.configuration.polls!=null && instance.configuration.polls.maxOptions>0)
maxPollOptions=instance.configuration.polls.maxOptions;
if(instance!=null && instance.configuration!=null && instance.configuration.polls!=null && instance.configuration.polls.maxCharactersPerOption>0)
maxPollOptionLength=instance.configuration.polls.maxCharactersPerOption;
if (!instance.isAkkoma()) {
if(instance!=null && instance.configuration!=null && instance.configuration.polls!=null && instance.configuration.polls.maxOptions>0)
maxPollOptions=instance.configuration.polls.maxOptions;
if(instance!=null && instance.configuration!=null && instance.configuration.polls!=null && instance.configuration.polls.maxCharactersPerOption>0)
maxPollOptionLength=instance.configuration.polls.maxCharactersPerOption;
} else {
if(instance!=null && instance.pollLimits!=null && instance.pollLimits.maxOptions>0)
maxPollOptions= (int) instance.pollLimits.maxOptions;
if(instance!=null && instance.pollLimits!=null && instance.pollLimits.maxOptionChars>0)
maxPollOptionLength= (int) instance.pollLimits.maxOptionChars;
}
pollOptionsView=pollWrap.findViewById(R.id.poll_options);
addPollOptionBtn=pollWrap.findViewById(R.id.add_poll_option);
@@ -127,7 +135,10 @@ public class ComposePollViewController{
DraftPollOption opt=createDraftPollOption(false);
opt.edit.setText(eopt.title);
}
pollDuration=(int)fragment.editingStatus.poll.expiresAt.minus(fragment.editingStatus.createdAt.toEpochMilli(), ChronoUnit.MILLIS).getEpochSecond();
if(fragment.scheduledStatus!=null && fragment.scheduledStatus.params.poll!=null)
pollDuration=Integer.parseInt(fragment.scheduledStatus.params.poll.expiresIn);
else if(fragment.editingStatus.poll.expiresAt!=null)
pollDuration=(int)fragment.editingStatus.poll.expiresAt.minus(fragment.editingStatus.createdAt.toEpochMilli(), ChronoUnit.MILLIS).getEpochSecond();
updatePollOptionHints();
pollDurationValue.setText(UiUtils.formatDuration(fragment.getContext(), pollDuration));
pollIsMultipleChoice=fragment.editingStatus.poll.multiple;

View File

@@ -53,7 +53,7 @@ public abstract class DropdownSubmenuController{
backItem=(TextView) dropdownController.getActivity().getLayoutInflater().inflate(R.layout.item_dropdown_menu, contentView, false);
((LinearLayout.LayoutParams) backItem.getLayoutParams()).topMargin=V.dp(8);
backItem.setText(backTitle);
backItem.setCompoundDrawablesRelativeWithIntrinsicBounds(R.drawable.ic_arrow_back, 0, 0, 0);
backItem.setCompoundDrawablesRelativeWithIntrinsicBounds(R.drawable.ic_fluent_arrow_left_24_regular, 0, 0, 0);
backItem.setBackground(UiUtils.getThemeDrawable(dropdownController.getActivity(), android.R.attr.selectableItemBackground));
backItem.setOnClickListener(v->dropdownController.popSubmenuController());
backItem.setAccessibilityDelegate(new View.AccessibilityDelegate(){

View File

@@ -40,7 +40,7 @@ public class HomeTimelineHashtagsMenuController extends DropdownSubmenuControlle
@Override
protected void createView(){
super.createView();
emptyAdapter=createEmptyView(R.drawable.ic_tag_24px, R.string.no_followed_hashtags_title, R.string.no_followed_hashtags_subtitle);
emptyAdapter=createEmptyView(R.drawable.ic_fluent_tag_24_regular, R.string.no_followed_hashtags_title, R.string.no_followed_hashtags_subtitle);
FrameLayout largeProgressView=new FrameLayout(dropdownController.getActivity());
int pad=V.dp(32);
largeProgressView.setPadding(0, pad, 0, pad);

View File

@@ -3,11 +3,10 @@ package org.joinmastodon.android.ui.viewholders;
import android.annotation.SuppressLint;
import android.app.Activity;
import android.app.Fragment;
import android.app.ProgressDialog;
import android.content.Intent;
import android.content.res.ColorStateList;
import android.graphics.drawable.Animatable;
import android.graphics.drawable.Drawable;
import android.os.Build;
import android.os.Bundle;
import android.text.SpannableStringBuilder;
import android.text.style.TypefaceSpan;
@@ -15,8 +14,8 @@ import android.view.Menu;
import android.view.MenuItem;
import android.view.View;
import android.view.ViewGroup;
import android.widget.Button;
import android.widget.CheckBox;
import android.widget.FrameLayout;
import android.widget.ImageButton;
import android.widget.ImageView;
import android.widget.PopupMenu;
@@ -24,9 +23,11 @@ import android.widget.ProgressBar;
import android.widget.RadioButton;
import android.widget.TextView;
import org.joinmastodon.android.GlobalUserPreferences;
import org.joinmastodon.android.R;
import org.joinmastodon.android.api.requests.accounts.SetAccountFollowed;
import org.joinmastodon.android.api.session.AccountSessionManager;
import org.joinmastodon.android.fragments.ListsFragment;
import org.joinmastodon.android.fragments.AddAccountToListsFragment;
import org.joinmastodon.android.fragments.ProfileFragment;
import org.joinmastodon.android.fragments.report.ReportReasonChoiceFragment;
@@ -34,6 +35,7 @@ import org.joinmastodon.android.model.Account;
import org.joinmastodon.android.model.Relationship;
import org.joinmastodon.android.model.viewmodel.AccountViewModel;
import org.joinmastodon.android.ui.OutlineProviders;
import org.joinmastodon.android.ui.text.HtmlParser;
import org.joinmastodon.android.ui.utils.UiUtils;
import org.joinmastodon.android.ui.views.CheckableRelativeLayout;
import org.joinmastodon.android.ui.views.ProgressBarButton;
@@ -41,6 +43,7 @@ import org.parceler.Parcels;
import java.util.HashMap;
import java.util.Objects;
import java.util.Optional;
import java.util.function.Consumer;
import java.util.function.Predicate;
@@ -53,8 +56,9 @@ import me.grishka.appkit.utils.BindableViewHolder;
import me.grishka.appkit.views.UsableRecyclerView;
public class AccountViewHolder extends BindableViewHolder<AccountViewModel> implements ImageLoaderViewHolder, UsableRecyclerView.Clickable, UsableRecyclerView.LongClickable{
private final TextView name, username, followers, verifiedLink, bio;
private final TextView name, username, followers, pronouns, bio;
public final ImageView avatar;
private final FrameLayout accessory;
private final ProgressBarButton button;
private final PopupMenu contextMenu;
private final View menuAnchor;
@@ -89,10 +93,11 @@ public class AccountViewHolder extends BindableViewHolder<AccountViewModel> impl
name=findViewById(R.id.name);
username=findViewById(R.id.username);
avatar=findViewById(R.id.avatar);
accessory=findViewById(R.id.accessory);
button=findViewById(R.id.button);
menuAnchor=findViewById(R.id.menu_anchor);
followers=findViewById(R.id.followers_count);
verifiedLink=findViewById(R.id.verified_link);
pronouns=findViewById(R.id.pronouns);
bio=findViewById(R.id.bio);
checkbox=findViewById(R.id.checkbox);
actionProgress=findViewById(R.id.action_progress);
@@ -102,11 +107,15 @@ public class AccountViewHolder extends BindableViewHolder<AccountViewModel> impl
avatar.setClipToOutline(true);
button.setOnClickListener(this::onButtonClick);
accessory.setOnClickListener(v -> button.performClick());
contextMenu=new PopupMenu(fragment.getActivity(), menuAnchor);
contextMenu.inflate(R.menu.profile);
contextMenu.setOnMenuItemClickListener(this::onContextMenuItemSelected);
menuButton.setOnClickListener(v->showMenuFromButton());
if(Build.VERSION.SDK_INT>=Build.VERSION_CODES.P && !UiUtils.isEMUI())
contextMenu.getMenu().setGroupDividerEnabled(true);
UiUtils.enablePopupMenuIcons(fragment.getContext(), contextMenu);
setStyle(AccessoryType.BUTTON, false);
}
@@ -116,28 +125,40 @@ public class AccountViewHolder extends BindableViewHolder<AccountViewModel> impl
public void onBind(AccountViewModel item){
name.setText(item.parsedName);
username.setText("@"+item.account.acct);
if(followers!=null){
String followersStr=fragment.getResources().getQuantityString(R.plurals.x_followers, item.account.followersCount>1000 ? 999 : (int)item.account.followersCount);
String followersNum=UiUtils.abbreviateNumber(item.account.followersCount);
int index=followersStr.indexOf("%,d");
followersStr=followersStr.replace("%,d", followersNum);
SpannableStringBuilder followersFormatted=new SpannableStringBuilder(followersStr);
if(index!=-1){
followersFormatted.setSpan(mediumSpan, index, index+followersNum.length(), 0);
}
long num=item.account.followersCount > -1 ? item.account.followersCount : item.account.followingCount;
String followersStr = fragment.getResources().getQuantityString(item.account.followersCount > -1
? R.plurals.x_followers : R.plurals.x_following, num>1000 ? 999 : (int)num);
String followersNum=UiUtils.abbreviateNumber(num);
int index=followersStr.indexOf("%,d");
followersStr=followersStr.replace("%,d", followersNum);
SpannableStringBuilder followersFormatted=new SpannableStringBuilder(followersStr);
if(index!=-1){
followersFormatted.setSpan(mediumSpan, index, index+followersNum.length(), 0);
}
if (item.account.followingCount > -1 || item.account.followersCount > -1) {
followers.setVisibility(View.VISIBLE);
followers.setText(followersFormatted);
} else {
followers.setVisibility(View.GONE);
}
if(verifiedLink!=null){
boolean hasVerifiedLink=item.verifiedLink!=null;
if(!hasVerifiedLink)
verifiedLink.setText(R.string.no_verified_link);
else
verifiedLink.setText(item.verifiedLink);
verifiedLink.setCompoundDrawablesRelativeWithIntrinsicBounds(hasVerifiedLink ? R.drawable.ic_check_small_16px : R.drawable.ic_help_16px, 0, 0, 0);
int tintColor=UiUtils.getThemeColor(fragment.getActivity(), hasVerifiedLink ? R.attr.colorM3Primary : R.attr.colorM3Secondary);
verifiedLink.setTextColor(tintColor);
verifiedLink.setCompoundDrawableTintList(ColorStateList.valueOf(tintColor));
}
// you know what's cooler than followers or verified links? yep. pronouns
Optional<String> pronounsString=GlobalUserPreferences.displayPronounsInUserListings
? UiUtils.extractPronouns(itemView.getContext(), item.account) : Optional.empty();
pronouns.setVisibility(pronounsString.isPresent() ? View.VISIBLE : View.GONE);
pronounsString.ifPresent(p -> HtmlParser.setTextWithCustomEmoji(pronouns, p, item.account.emojis));
/* unused in megalodon
boolean hasVerifiedLink=item.verifiedLink!=null;
if(!hasVerifiedLink)
verifiedLink.setText(R.string.no_verified_link);
else
verifiedLink.setText(item.verifiedLink);
verifiedLink.setCompoundDrawablesRelativeWithIntrinsicBounds(hasVerifiedLink ? R.drawable.ic_fluent_checkmark_16_filled : R.drawable.ic_help_16px, 0, 0, 0);
int tintColor=UiUtils.getThemeColor(fragment.getActivity(), hasVerifiedLink ? R.attr.colorM3Primary : R.attr.colorM3Secondary);
verifiedLink.setTextColor(tintColor);
verifiedLink.setCompoundDrawableTintList(ColorStateList.valueOf(tintColor));
*/
bindRelationship();
if(showBio){
bio.setText(item.parsedBio);
@@ -187,7 +208,10 @@ public class AccountViewHolder extends BindableViewHolder<AccountViewModel> impl
}
Bundle args=new Bundle();
args.putString("account", accountID);
args.putParcelable("profileAccount", Parcels.wrap(item.account));
if (item.account.isRemote)
args.putParcelable("remoteAccount", Parcels.wrap(item.account));
else
args.putParcelable("profileAccount", Parcels.wrap(item.account));
Nav.go(fragment.getActivity(), ProfileFragment.class, args);
}
@@ -202,6 +226,41 @@ public class AccountViewHolder extends BindableViewHolder<AccountViewModel> impl
return true;
if(accessoryType==AccessoryType.MENU || !prepareMenu())
return false;
if(relationships==null)
return false;
Relationship relationship=relationships.get(item.account.id);
if(relationship==null)
return false;
Menu menu=contextMenu.getMenu();
Account account=item.account;
menu.findItem(R.id.edit_note).setVisible(false);
menu.findItem(R.id.manage_user_lists).setTitle(fragment.getString(R.string.sk_lists_with_user, account.getShortUsername()));
MenuItem mute=menu.findItem(R.id.mute);
mute.setTitle(fragment.getString(relationship.muting ? R.string.unmute_user : R.string.mute_user, account.getShortUsername()));
mute.setIcon(relationship.muting ? R.drawable.ic_fluent_speaker_0_24_regular : R.drawable.ic_fluent_speaker_off_24_regular);
UiUtils.insetPopupMenuIcon(fragment.getContext(), mute);
menu.findItem(R.id.block).setTitle(fragment.getString(relationship.blocking ? R.string.unblock_user : R.string.block_user, account.getShortUsername()));
menu.findItem(R.id.report).setTitle(fragment.getString(R.string.report_user, account.getShortUsername()));
menu.findItem(R.id.manage_user_lists).setVisible(relationship.following);
menu.findItem(R.id.soft_block).setVisible(relationship.followedBy && !relationship.following);
MenuItem hideBoosts=menu.findItem(R.id.hide_boosts);
if(relationship.following){
hideBoosts.setTitle(fragment.getString(relationship.showingReblogs ? R.string.hide_boosts_from_user : R.string.show_boosts_from_user, account.getShortUsername()));
hideBoosts.setIcon(relationship.showingReblogs ? R.drawable.ic_fluent_arrow_repeat_all_off_24_regular : R.drawable.ic_fluent_arrow_repeat_all_24_regular);
UiUtils.insetPopupMenuIcon(fragment.getContext(), hideBoosts);
hideBoosts.setVisible(true);
}else{
hideBoosts.setVisible(false);
}
MenuItem blockDomain=menu.findItem(R.id.block_domain);
if(!account.isLocal()){
blockDomain.setTitle(fragment.getString(relationship.domainBlocking ? R.string.unblock_domain : R.string.block_domain, account.getDomain()));
blockDomain.setVisible(true);
}else{
blockDomain.setVisible(false);
}
menuAnchor.setTranslationX(x);
menuAnchor.setTranslationY(y);
contextMenu.show();
@@ -243,6 +302,8 @@ public class AccountViewHolder extends BindableViewHolder<AccountViewModel> impl
UiUtils.confirmToggleMuteUser(fragment.getActivity(), accountID, account, relationship.muting, this::updateRelationship);
}else if(id==R.id.block){
UiUtils.confirmToggleBlockUser(fragment.getActivity(), accountID, account, relationship.blocking, this::updateRelationship);
}else if(id==R.id.soft_block){
UiUtils.confirmSoftBlockUser(fragment.getActivity(), accountID, account, this::updateRelationship);
}else if(id==R.id.report){
Bundle args=new Bundle();
args.putString("account", accountID);
@@ -252,10 +313,10 @@ public class AccountViewHolder extends BindableViewHolder<AccountViewModel> impl
}else if(id==R.id.open_in_browser){
UiUtils.launchWebBrowser(fragment.getActivity(), account.url);
}else if(id==R.id.block_domain){
UiUtils.confirmToggleBlockDomain(fragment.getActivity(), accountID, account, relationship.domainBlocking, ()->{
UiUtils.confirmToggleBlockDomain(fragment.getActivity(), accountID, account.getDomain(), relationship.domainBlocking, ()->{
relationship.domainBlocking=!relationship.domainBlocking;
bindRelationship();
}, this::updateRelationship);
});
}else if(id==R.id.hide_boosts){
new SetAccountFollowed(account.id, true, !relationship.showingReblogs)
.setCallback(new Callback<>(){

View File

@@ -40,11 +40,9 @@ public abstract class ListItemViewHolder<T extends ListItem<?>> extends Bindable
if(TextUtils.isEmpty(item.subtitle) && item.subtitleRes==0){
subtitle.setVisibility(View.GONE);
title.setMaxLines(2);
view.setMinimumHeight(V.dp(56));
}else{
subtitle.setVisibility(View.VISIBLE);
title.setMaxLines(1);
view.setMinimumHeight(V.dp(72));
if(TextUtils.isEmpty(item.subtitle))
subtitle.setText(item.subtitleRes);

View File

@@ -3,13 +3,13 @@ package org.joinmastodon.android.ui.views;
import android.annotation.SuppressLint;
import android.content.Context;
import android.util.AttributeSet;
import android.view.View;
import android.view.ViewGroup;
import org.joinmastodon.android.ui.utils.UiUtils;
import me.grishka.appkit.utils.V;
public class ComposeMediaLayout extends ViewGroup{
private static final int MAX_WIDTH_DP=400;
private static final int GAP_DP=8;
private static final float ASPECT_RATIO=0.5625f;
@@ -30,7 +30,7 @@ public class ComposeMediaLayout extends ViewGroup{
int mode=MeasureSpec.getMode(widthMeasureSpec);
@SuppressLint("SwitchIntDef")
int width=switch(mode){
case MeasureSpec.AT_MOST -> Math.min(V.dp(MAX_WIDTH_DP), MeasureSpec.getSize(widthMeasureSpec));
case MeasureSpec.AT_MOST -> Math.min(UiUtils.MAX_WIDTH, MeasureSpec.getSize(widthMeasureSpec));
case MeasureSpec.EXACTLY -> MeasureSpec.getSize(widthMeasureSpec);
default -> throw new IllegalArgumentException("unsupported measure mode");
};

View File

@@ -0,0 +1,44 @@
package org.joinmastodon.android.ui.views;
import android.content.Context;
import android.util.AttributeSet;
import android.view.MotionEvent;
import me.grishka.appkit.views.UsableRecyclerView;
public class EmojiReactionsRecyclerView extends UsableRecyclerView{
public EmojiReactionsRecyclerView(Context context){
super(context);
}
public EmojiReactionsRecyclerView(Context context, AttributeSet attrs){
super(context, attrs);
}
public EmojiReactionsRecyclerView(Context context, AttributeSet attrs, int defStyle){
super(context, attrs, defStyle);
}
@Override
public boolean onTouchEvent(MotionEvent e){
super.onTouchEvent(e);
// to pass through touch events (i.e. clicking the status) to the parent view
return false;
}
// https://stackoverflow.com/questions/55372837/is-there-a-way-to-make-recyclerview-requiresfadingedge-unaffected-by-paddingtop
@Override
protected boolean isPaddingOffsetRequired() {
return true;
}
@Override
protected int getLeftPaddingOffset(){
return -getPaddingLeft();
}
@Override
protected int getRightPaddingOffset() {
return getPaddingRight();
}
}

View File

@@ -3,6 +3,7 @@ package org.joinmastodon.android.ui.views;
import android.content.Context;
import android.graphics.drawable.Drawable;
import android.util.AttributeSet;
import android.view.Gravity;
import org.joinmastodon.android.R;
import org.joinmastodon.android.ui.utils.UiUtils;
@@ -25,6 +26,8 @@ public class FilterChipView extends CheckIconSelectableTextView{
setCompoundDrawablePadding(V.dp(8));
setBackgroundResource(R.drawable.bg_filter_chip);
setTextAppearance(R.style.m3_label_large);
setHeight(V.dp(48));
setGravity(Gravity.CENTER_VERTICAL);
setTextColor(getResources().getColorStateList(R.color.filter_chip_text, context.getTheme()));
updatePadding();
}
@@ -36,7 +39,7 @@ public class FilterChipView extends CheckIconSelectableTextView{
}
private void updatePadding(){
int vertical=V.dp(6);
int vertical=0;
Drawable[] drawables=getCompoundDrawablesRelative();
setPaddingRelative(V.dp(drawables[0]==null ? 16 : 8), vertical, V.dp(drawables[2]==null ? 16 : 8), vertical);
}

View File

@@ -1,31 +1,40 @@
package org.joinmastodon.android.ui.views;
import android.content.Context;
import android.content.res.TypedArray;
import android.util.AttributeSet;
import android.view.View;
import android.widget.LinearLayout;
import android.widget.TextView;
import org.joinmastodon.android.R;
/**
* A LinearLayout for TextViews. First child TextView will get truncated if it doesn't fit, remaining will always wrap content.
*/
public class HeaderSubtitleLinearLayout extends LinearLayout{
private float firstFraction;
public HeaderSubtitleLinearLayout(Context context){
super(context);
this(context, null);
}
public HeaderSubtitleLinearLayout(Context context, AttributeSet attrs){
super(context, attrs);
this(context, attrs, 0);
}
public HeaderSubtitleLinearLayout(Context context, AttributeSet attrs, int defStyleAttr){
super(context, attrs, defStyleAttr);
TypedArray ta=context.obtainStyledAttributes(attrs, R.styleable.HeaderSubtitleLinearLayout);
firstFraction=ta.getFraction(R.styleable.HeaderSubtitleLinearLayout_firstFraction, 1, 1, 0.5f);
ta.recycle();
}
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec){
if(getLayoutChildCount()>1){
int remainingWidth=MeasureSpec.getSize(widthMeasureSpec);
int fullWidth=MeasureSpec.getSize(widthMeasureSpec);
int remainingWidth=fullWidth;
for(int i=1;i<getChildCount();i++){
View v=getChildAt(i);
if(v.getVisibility()==GONE)
@@ -36,7 +45,7 @@ public class HeaderSubtitleLinearLayout extends LinearLayout{
}
View first=getChildAt(0);
if(first instanceof TextView){
((TextView) first).setMaxWidth(remainingWidth);
((TextView) first).setMaxWidth(Math.max(remainingWidth, (int)(firstFraction*fullWidth)));
}
}else{
View first=getChildAt(0);
@@ -55,4 +64,12 @@ public class HeaderSubtitleLinearLayout extends LinearLayout{
}
return count;
}
public void setFirstFraction(float firstFraction){
this.firstFraction=firstFraction;
}
public float getFirstFraction(){
return firstFraction;
}
}

View File

@@ -0,0 +1,97 @@
package org.joinmastodon.android.ui.views;
import android.annotation.SuppressLint;
import android.content.Context;
import android.util.AttributeSet;
import android.view.Gravity;
import android.view.LayoutInflater;
import android.view.MenuItem;
import android.widget.Button;
import android.widget.LinearLayout;
import android.widget.PopupMenu;
import android.widget.Switch;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import org.joinmastodon.android.R;
import org.joinmastodon.android.model.ListTimeline;
public class ListEditor extends LinearLayout {
private ListTimeline.RepliesPolicy policy = null;
private final TextInputFrameLayout input;
private final Button button;
private final Switch exclusiveSwitch;
@SuppressLint("ClickableViewAccessibility")
public ListEditor(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) {
super(context, attrs, defStyleAttr, defStyleRes);
LayoutInflater.from(context).inflate(R.layout.list_timeline_editor, this);
button = findViewById(R.id.button);
input = findViewById(R.id.input);
exclusiveSwitch = findViewById(R.id.exclusive_checkbox);
PopupMenu popupMenu = new PopupMenu(context, button, Gravity.CENTER_HORIZONTAL);
popupMenu.inflate(R.menu.list_reply_policies);
popupMenu.setOnMenuItemClickListener(this::onMenuItemClick);
button.setOnTouchListener(popupMenu.getDragToOpenListener());
button.setOnClickListener(v->popupMenu.show());
input.getEditText().setHint(context.getString(R.string.sk_list_name_hint));
findViewById(R.id.exclusive)
.setOnClickListener(v -> exclusiveSwitch.setChecked(!exclusiveSwitch.isChecked()));
setRepliesPolicy(ListTimeline.RepliesPolicy.LIST);
}
public void applyList(String title, boolean exclusive, @Nullable ListTimeline.RepliesPolicy policy) {
input.getEditText().setText(title);
exclusiveSwitch.setChecked(exclusive);
if (policy != null) setRepliesPolicy(policy);
}
public String getTitle() {
return input.getEditText().getText().toString();
}
public ListTimeline.RepliesPolicy getRepliesPolicy() {
return policy;
}
public boolean isExclusive() {
return exclusiveSwitch.isChecked();
}
public void setRepliesPolicy(@NonNull ListTimeline.RepliesPolicy policy) {
this.policy = policy;
switch (policy) {
case FOLLOWED -> button.setText(R.string.sk_list_replies_policy_followed);
case LIST -> button.setText(R.string.sk_list_replies_policy_list);
case NONE -> button.setText(R.string.sk_list_replies_policy_none);
}
}
private boolean onMenuItemClick(MenuItem i) {
if (i.getItemId() == R.id.reply_policy_none) {
setRepliesPolicy(ListTimeline.RepliesPolicy.NONE);
} else if (i.getItemId() == R.id.reply_policy_followed) {
setRepliesPolicy(ListTimeline.RepliesPolicy.FOLLOWED);
} else if (i.getItemId() == R.id.reply_policy_list) {
setRepliesPolicy(ListTimeline.RepliesPolicy.LIST);
}
return true;
}
public ListEditor(Context context, AttributeSet attrs, int defStyleAttr) {
this(context, attrs, defStyleAttr, 0);
}
public ListEditor(Context context, AttributeSet attrs) {
this(context, attrs, 0);
}
public ListEditor(Context context) {
this(context, null);
}
}

View File

@@ -3,12 +3,13 @@ package org.joinmastodon.android.ui.views;
import android.content.Context;
import android.content.res.TypedArray;
import android.util.AttributeSet;
import android.view.ViewGroup;
import android.widget.FrameLayout;
import org.joinmastodon.android.R;
public class MaxWidthFrameLayout extends FrameLayout{
private int maxWidth;
private int maxWidth, defaultWidth;
public MaxWidthFrameLayout(Context context){
this(context, null);
@@ -22,6 +23,7 @@ public class MaxWidthFrameLayout extends FrameLayout{
super(context, attrs, defStyle);
TypedArray ta=context.obtainStyledAttributes(attrs, R.styleable.MaxWidthFrameLayout);
maxWidth=ta.getDimensionPixelSize(R.styleable.MaxWidthFrameLayout_android_maxWidth, Integer.MAX_VALUE);
defaultWidth=ta.getDimensionPixelSize(R.styleable.MaxWidthFrameLayout_defaultWidth, -1);
ta.recycle();
}
@@ -33,10 +35,19 @@ public class MaxWidthFrameLayout extends FrameLayout{
this.maxWidth=maxWidth;
}
public int getDefaultWidth() {
return defaultWidth;
}
public void setDefaultWidth(int defaultWidth) {
this.defaultWidth = defaultWidth;
}
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec){
if(MeasureSpec.getSize(widthMeasureSpec)>maxWidth){
widthMeasureSpec=maxWidth | MeasureSpec.getMode(widthMeasureSpec);
int width = defaultWidth >= 0 ? defaultWidth : maxWidth;
widthMeasureSpec=width | MeasureSpec.getMode(widthMeasureSpec);
}
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
}

View File

@@ -6,13 +6,13 @@ import android.view.View;
import android.view.ViewGroup;
import org.joinmastodon.android.ui.PhotoLayoutHelper;
import org.joinmastodon.android.ui.utils.UiUtils;
import me.grishka.appkit.utils.V;
public class MediaGridLayout extends ViewGroup{
private static final String TAG="MediaGridLayout";
public static final int MAX_WIDTH=400; // dp
private static final int GAP=2; // dp
private PhotoLayoutHelper.TiledLayoutResult tiledLayout;
private int[] columnStarts, columnEnds, rowStarts, rowEnds;
@@ -27,7 +27,6 @@ public class MediaGridLayout extends ViewGroup{
public MediaGridLayout(Context context, AttributeSet attrs, int defStyle){
super(context, attrs, defStyle);
}
@Override
@@ -36,7 +35,7 @@ public class MediaGridLayout extends ViewGroup{
setMeasuredDimension(MeasureSpec.getSize(widthMeasureSpec), 0);
return;
}
int width=Math.min(V.dp(MAX_WIDTH), MeasureSpec.getSize(widthMeasureSpec));
int width=Math.min(UiUtils.MAX_WIDTH, MeasureSpec.getSize(widthMeasureSpec));
int height=Math.round(width*(tiledLayout.height/(float)PhotoLayoutHelper.MAX_WIDTH));
if(tiledLayout.width<PhotoLayoutHelper.MAX_WIDTH){
width=Math.round(width*(tiledLayout.width/(float)PhotoLayoutHelper.MAX_WIDTH));
@@ -85,7 +84,7 @@ public class MediaGridLayout extends ViewGroup{
if(tiledLayout==null || rowStarts==null)
return;
int maxWidth=V.dp(MAX_WIDTH);
int maxWidth=UiUtils.MAX_WIDTH;
if(tiledLayout.width<PhotoLayoutHelper.MAX_WIDTH){
maxWidth=Math.round((r-l)*(tiledLayout.width/(float)PhotoLayoutHelper.MAX_WIDTH));
}

View File

@@ -1,42 +1,23 @@
package org.joinmastodon.android.ui.views;
import android.content.Context;
import android.content.res.TypedArray;
import android.graphics.Canvas;
import android.util.AttributeSet;
import android.view.View;
import android.view.ViewGroup;
import android.widget.Button;
import android.widget.ProgressBar;
import org.joinmastodon.android.R;
public class ProgressBarButton extends Button{
private boolean textVisible=true;
private ProgressBar progressBar;
private int progressBarID;
public ProgressBarButton(Context context){
this(context, null);
super(context);
}
public ProgressBarButton(Context context, AttributeSet attrs){
this(context, attrs, 0);
super(context, attrs);
}
public ProgressBarButton(Context context, AttributeSet attrs, int defStyle){
super(context, attrs, defStyle);
TypedArray ta=context.obtainStyledAttributes(attrs, R.styleable.ProgressBarButton);
progressBarID=ta.getResourceId(R.styleable.ProgressBarButton_progressBar, 0);
ta.recycle();
}
@Override
protected void onAttachedToWindow(){
super.onAttachedToWindow();
if(progressBarID!=0){
progressBar=((ViewGroup)getParent()).findViewById(progressBarID);
}
public ProgressBarButton(Context context, AttributeSet attrs, int defStyleAttr){
super(context, attrs, defStyleAttr);
}
public void setTextVisible(boolean textVisible){
@@ -48,19 +29,6 @@ public class ProgressBarButton extends Button{
return textVisible;
}
public void setProgressBarVisible(boolean visible){
if(progressBar==null)
throw new IllegalStateException("progressBar is not set");
if(visible){
setTextVisible(false);
progressBar.setIndeterminateTintList(getTextColors());
progressBar.setVisibility(View.VISIBLE);
}else{
setTextVisible(true);
progressBar.setVisibility(View.GONE);
}
}
@Override
protected void onDraw(Canvas canvas){
if(textVisible){

View File

@@ -2,15 +2,12 @@ package org.joinmastodon.android.ui.views;
import android.content.Context;
import android.util.AttributeSet;
import android.util.Log;
import android.view.MotionEvent;
import android.view.View;
import android.view.ViewTreeObserver;
import android.view.animation.Interpolator;
import android.widget.LinearLayout;
import org.joinmastodon.android.R;
import androidx.annotation.Nullable;
import me.grishka.appkit.utils.CubicBezierInterpolator;
import me.grishka.appkit.utils.CustomViewHelper;
@@ -284,7 +281,7 @@ public class ReorderableLinearLayout extends LinearLayout implements CustomViewH
private int getMaxDragScroll(){
if(cachedMaxScrollSpeed==-1){
cachedMaxScrollSpeed=getResources().getDimensionPixelSize(R.dimen.item_touch_helper_max_drag_scroll_per_frame);
cachedMaxScrollSpeed=getResources().getDimensionPixelSize(androidx.recyclerview.R.dimen.item_touch_helper_max_drag_scroll_per_frame);
}
return cachedMaxScrollSpeed;
}

View File

@@ -1,170 +0,0 @@
package org.joinmastodon.android.ui.views;
import android.animation.ArgbEvaluator;
import android.content.Context;
import android.graphics.Canvas;
import android.graphics.Paint;
import android.text.Layout;
import android.util.AttributeSet;
import android.widget.TextView;
import androidx.dynamicanimation.animation.FloatValueHolder;
import androidx.dynamicanimation.animation.SpringAnimation;
import androidx.dynamicanimation.animation.SpringForce;
import me.grishka.appkit.utils.CustomViewHelper;
public class RippleAnimationTextView extends TextView implements CustomViewHelper{
private final Paint animationPaint=new Paint(Paint.ANTI_ALIAS_FLAG);
private CharacterAnimationState[] charStates;
private final ArgbEvaluator colorEvaluator=new ArgbEvaluator();
private int runningAnimCount=0;
private Runnable[] delayedAnimations1, delayedAnimations2;
public RippleAnimationTextView(Context context){
this(context, null);
}
public RippleAnimationTextView(Context context, AttributeSet attrs){
this(context, attrs, 0);
}
public RippleAnimationTextView(Context context, AttributeSet attrs, int defStyle){
super(context, attrs, defStyle);
}
@Override
protected void onTextChanged(CharSequence text, int start, int lengthBefore, int lengthAfter){
super.onTextChanged(text, start, lengthBefore, lengthAfter);
if(charStates!=null){
for(CharacterAnimationState state:charStates){
state.colorAnimation.cancel();
state.shadowAnimation.cancel();
state.scaleAnimation.cancel();
}
for(Runnable r:delayedAnimations1){
if(r!=null)
removeCallbacks(r);
}
for(Runnable r:delayedAnimations2){
if(r!=null)
removeCallbacks(r);
}
}
charStates=new CharacterAnimationState[lengthAfter];
delayedAnimations1=new Runnable[lengthAfter];
delayedAnimations2=new Runnable[lengthAfter];
}
@Override
protected void onDraw(Canvas canvas){
if(runningAnimCount==0 && !areThereDelayedAnimations()){
super.onDraw(canvas);
return;
}
Layout layout=getLayout();
animationPaint.set(getPaint());
CharSequence text=layout.getText();
for(int i=0;i<layout.getLineCount();i++){
int baseline=layout.getLineBaseline(i);
for(int offset=layout.getLineStart(i); offset<layout.getLineEnd(i); offset++){
float x=layout.getPrimaryHorizontal(offset);
CharacterAnimationState state=charStates[offset];
if(state==null || state.scaleAnimation==null){
animationPaint.setColor(getCurrentTextColor());
animationPaint.clearShadowLayer();
canvas.drawText(text, offset, offset+1, x, baseline, animationPaint);
}else{
animationPaint.setColor((int)colorEvaluator.evaluate(Math.max(0, Math.min(1, state.color.getValue())), getCurrentTextColor(), getLinkTextColors().getDefaultColor()));
float scale=state.scale.getValue();
int shadowAlpha=Math.round(255*Math.max(0, Math.min(1, state.shadowAlpha.getValue())));
animationPaint.setShadowLayer(dp(4), 0, dp(3), (getPaint().linkColor & 0xFFFFFF) | (shadowAlpha << 24));
canvas.save();
canvas.scale(scale, scale, x, baseline);
canvas.drawText(text, offset, offset+1, x, baseline, animationPaint);
canvas.restore();
}
}
}
invalidate();
}
public void animate(int startIndex, int endIndex){
for(int i=startIndex;i<endIndex;i++){
CharacterAnimationState _state=charStates[i];
if(_state==null){
_state=charStates[i]=new CharacterAnimationState();
}
CharacterAnimationState state=_state;
int finalI=i;
postOnAnimationDelayed(()->{
if(!state.colorAnimation.isRunning())
runningAnimCount++;
state.colorAnimation.animateToFinalPosition(1f);
if(!state.shadowAnimation.isRunning())
runningAnimCount++;
state.shadowAnimation.animateToFinalPosition(0.3f);
if(!state.scaleAnimation.isRunning())
runningAnimCount++;
state.scaleAnimation.animateToFinalPosition(1.2f);
invalidate();
if(delayedAnimations1[finalI]!=null)
removeCallbacks(delayedAnimations1[finalI]);
if(delayedAnimations2[finalI]!=null)
removeCallbacks(delayedAnimations2[finalI]);
Runnable delay1=()->{
if(!state.colorAnimation.isRunning())
runningAnimCount++;
state.colorAnimation.animateToFinalPosition(0f);
if(!state.shadowAnimation.isRunning())
runningAnimCount++;
state.shadowAnimation.animateToFinalPosition(0f);
invalidate();
delayedAnimations1[finalI]=null;
};
Runnable delay2=()->{
if(!state.scaleAnimation.isRunning())
runningAnimCount++;
state.scaleAnimation.animateToFinalPosition(1f);
delayedAnimations2[finalI]=null;
};
delayedAnimations1[finalI]=delay1;
delayedAnimations2[finalI]=delay2;
postOnAnimationDelayed(delay1, 2000);
postOnAnimationDelayed(delay2, 100);
}, 20L*(i-startIndex));
}
}
private boolean areThereDelayedAnimations(){
for(Runnable r:delayedAnimations1){
if(r!=null)
return true;
}
for(Runnable r:delayedAnimations2){
if(r!=null)
return true;
}
return false;
}
private class CharacterAnimationState extends FloatValueHolder{
private final SpringAnimation scaleAnimation, colorAnimation, shadowAnimation;
private final FloatValueHolder scale=new FloatValueHolder(1), color=new FloatValueHolder(), shadowAlpha=new FloatValueHolder();
public CharacterAnimationState(){
scaleAnimation=new SpringAnimation(scale);
colorAnimation=new SpringAnimation(color);
shadowAnimation=new SpringAnimation(shadowAlpha);
setupSpring(scaleAnimation);
setupSpring(colorAnimation);
setupSpring(shadowAnimation);
}
private void setupSpring(SpringAnimation anim){
anim.setMinimumVisibleChange(0.01f);
anim.setSpring(new SpringForce().setStiffness(500f).setDampingRatio(0.175f));
anim.addEndListener((animation, canceled, value, velocity)->runningAnimCount--);
}
}
}

View File

@@ -1,9 +1,11 @@
package org.joinmastodon.android.ui.views;
import android.content.Context;
import android.graphics.Typeface;
import android.util.AttributeSet;
import android.view.View;
import android.widget.LinearLayout;
import android.widget.TextView;
import java.util.function.IntConsumer;
import java.util.function.IntPredicate;
@@ -45,9 +47,7 @@ public class TabBar extends LinearLayout{
listener.accept(v.getId());
if(v.getId()==selectedTabID)
return;
findViewById(selectedTabID).setSelected(false);
v.setSelected(true);
selectedTabID=v.getId();
selectTab(v.getId());
}
private boolean onChildLongClick(View v){
@@ -60,8 +60,17 @@ public class TabBar extends LinearLayout{
}
public void selectTab(int id){
findViewById(selectedTabID).setSelected(false);
toggleSelected(selectedTabID, false);
selectedTabID=id;
findViewById(selectedTabID).setSelected(true);
toggleSelected(id, true);
}
private void toggleSelected(int selectedTabID, boolean selected){
LinearLayout tab=findViewById(selectedTabID);
tab.setSelected(selected);
View v=tab.findViewWithTag("label");
if(v instanceof TextView text){
text.setTypeface(Typeface.create(text.getTypeface(), selected ? Typeface.BOLD : Typeface.NORMAL));
}
}
}

View File

@@ -0,0 +1,51 @@
package org.joinmastodon.android.ui.views;
import android.content.Context;
import android.util.AttributeSet;
import android.view.ViewGroup;
import android.widget.EditText;
import android.widget.FrameLayout;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import me.grishka.appkit.utils.V;
public class TextInputFrameLayout extends FrameLayout {
private final EditText editText;
public TextInputFrameLayout(@NonNull Context context, CharSequence hint, CharSequence text) {
this(context, null, 0, 0, hint, text);
}
public TextInputFrameLayout(@NonNull Context context) {
this(context, null);
}
public TextInputFrameLayout(@NonNull Context context, @Nullable AttributeSet attrs) {
this(context, attrs, 0);
}
public TextInputFrameLayout(@NonNull Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
this(context, attrs, defStyleAttr, 0);
}
public TextInputFrameLayout(@NonNull Context context, @Nullable AttributeSet attrs, int defStyleAttr, int defStyleRes) {
this(context, attrs, defStyleAttr, defStyleRes, null, null);
}
public TextInputFrameLayout(@NonNull Context context, @Nullable AttributeSet attrs, int defStyleAttr, int defStyleRes, CharSequence hint, CharSequence text) {
super(context, attrs, defStyleAttr, defStyleRes);
editText = new EditText(context);
editText.setHint(hint);
editText.setText(text);
FrameLayout.LayoutParams params = new FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT);
params.setMargins(V.dp(24), V.dp(4), V.dp(24), 0);
editText.setLayoutParams(params);
addView(editText);
}
public EditText getEditText() {
return editText;
}
}

View File

@@ -0,0 +1,30 @@
package org.joinmastodon.android.ui.views;
import android.content.Context;
import android.util.AttributeSet;
import android.view.MotionEvent;
import android.widget.ScrollView;
public class UntouchableScrollView extends ScrollView {
public UntouchableScrollView(Context context) {
super(context);
}
public UntouchableScrollView(Context context, AttributeSet attrs) {
super(context, attrs);
}
public UntouchableScrollView(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
}
public UntouchableScrollView(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) {
super(context, attrs, defStyleAttr, defStyleRes);
}
@Override
public boolean onTouchEvent(MotionEvent event) {
super.onTouchEvent(event);
return false;
}
}