Pre-reply sheets

This commit is contained in:
Grishka
2023-11-15 18:05:38 +03:00
parent 45cc531eec
commit a438f633be
21 changed files with 503 additions and 40 deletions

View File

@@ -3,6 +3,9 @@ package org.joinmastodon.android;
import android.content.Context;
import android.content.SharedPreferences;
import org.joinmastodon.android.api.session.AccountSessionManager;
import org.joinmastodon.android.model.Account;
public class GlobalUserPreferences{
public static boolean playGifs;
public static boolean useCustomTabs;
@@ -13,6 +16,10 @@ public class GlobalUserPreferences{
return MastodonApp.context.getSharedPreferences("global", Context.MODE_PRIVATE);
}
private static SharedPreferences getPreReplyPrefs(){
return MastodonApp.context.getSharedPreferences("pre_reply_sheets", Context.MODE_PRIVATE);
}
public static void load(){
SharedPreferences prefs=getPrefs();
playGifs=prefs.getBoolean("playGifs", true);
@@ -36,9 +43,42 @@ public class GlobalUserPreferences{
.apply();
}
public static boolean isOptedOutOfPreReplySheet(PreReplySheetType type, Account account, String accountID){
if(getPreReplyPrefs().getBoolean("opt_out_"+type, false))
return true;
if(account==null)
return false;
String accountKey=account.acct;
if(!accountKey.contains("@"))
accountKey+="@"+AccountSessionManager.get(accountID).domain;
return getPreReplyPrefs().getBoolean("opt_out_"+type+"_"+accountKey.toLowerCase(), false);
}
public static void optOutOfPreReplySheet(PreReplySheetType type, Account account, String accountID){
String key;
if(account==null){
key="opt_out_"+type;
}else{
String accountKey=account.acct;
if(!accountKey.contains("@"))
accountKey+="@"+AccountSessionManager.get(accountID).domain;
key="opt_out_"+type+"_"+accountKey.toLowerCase();
}
getPreReplyPrefs().edit().putBoolean(key, true).apply();
}
public static void resetPreReplySheets(){
getPreReplyPrefs().edit().clear().apply();
}
public enum ThemePreference{
AUTO,
LIGHT,
DARK
}
public enum PreReplySheetType{
OLD_POST,
NON_MUTUAL
}
}

View File

@@ -14,10 +14,12 @@ import android.view.WindowInsets;
import android.widget.Toolbar;
import org.joinmastodon.android.E;
import org.joinmastodon.android.GlobalUserPreferences;
import org.joinmastodon.android.R;
import org.joinmastodon.android.api.requests.accounts.GetAccountRelationships;
import org.joinmastodon.android.api.requests.polls.SubmitPollVote;
import org.joinmastodon.android.api.requests.statuses.TranslateStatus;
import org.joinmastodon.android.api.session.AccountSessionManager;
import org.joinmastodon.android.events.PollUpdatedEvent;
import org.joinmastodon.android.model.Account;
import org.joinmastodon.android.model.DisplayItemsParent;
@@ -27,6 +29,8 @@ import org.joinmastodon.android.model.Status;
import org.joinmastodon.android.model.Translation;
import org.joinmastodon.android.ui.BetterItemAnimator;
import org.joinmastodon.android.ui.M3AlertDialogBuilder;
import org.joinmastodon.android.ui.NonMutualPreReplySheet;
import org.joinmastodon.android.ui.OldPostPreReplySheet;
import org.joinmastodon.android.ui.displayitems.AccountStatusDisplayItem;
import org.joinmastodon.android.ui.displayitems.ExtendedFooterStatusDisplayItem;
import org.joinmastodon.android.ui.displayitems.GapStatusDisplayItem;
@@ -43,6 +47,8 @@ import org.joinmastodon.android.ui.utils.MediaAttachmentViewController;
import org.joinmastodon.android.ui.utils.UiUtils;
import org.joinmastodon.android.utils.TypedObjectPool;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
@@ -106,6 +112,7 @@ public abstract class BaseStatusListFragment<T extends DisplayItemsParent> exten
for(T s:items){
displayItems.addAll(buildDisplayItems(s));
}
loadRelationships(items.stream().map(DisplayItemsParent::getAccountID).filter(Objects::nonNull).collect(Collectors.toSet()));
}
@Override
@@ -127,6 +134,7 @@ public abstract class BaseStatusListFragment<T extends DisplayItemsParent> exten
}
if(notify)
adapter.notifyItemRangeInserted(0, offset);
loadRelationships(items.stream().map(DisplayItemsParent::getAccountID).filter(Objects::nonNull).collect(Collectors.toSet()));
}
protected String getMaxID(){
@@ -455,6 +463,9 @@ public abstract class BaseStatusListFragment<T extends DisplayItemsParent> exten
}
protected void loadRelationships(Set<String> ids){
if(ids.isEmpty())
return;
ids=ids.stream().filter(id->!relationships.containsKey(id)).collect(Collectors.toSet());
if(ids.isEmpty())
return;
// TODO somehow manage these and cancel outstanding requests on refresh
@@ -641,6 +652,26 @@ public abstract class BaseStatusListFragment<T extends DisplayItemsParent> exten
adapter.notifyDataSetChanged();
}
public void maybeShowPreReplySheet(Status status, Runnable proceed){
Relationship rel=getRelationship(status.account.id);
if(!GlobalUserPreferences.isOptedOutOfPreReplySheet(GlobalUserPreferences.PreReplySheetType.NON_MUTUAL, status.account, accountID) &&
!status.account.id.equals(AccountSessionManager.get(accountID).self.id) && rel!=null && !rel.followedBy && status.account.followingCount>=1){
new NonMutualPreReplySheet(getActivity(), notAgain->{
GlobalUserPreferences.optOutOfPreReplySheet(GlobalUserPreferences.PreReplySheetType.NON_MUTUAL, notAgain ? null : status.account, accountID);
proceed.run();
}, status.account).show();
}else if(!GlobalUserPreferences.isOptedOutOfPreReplySheet(GlobalUserPreferences.PreReplySheetType.OLD_POST, null, null) &&
status.createdAt.isBefore(Instant.now().minus(90, ChronoUnit.DAYS))){
new OldPostPreReplySheet(getActivity(), notAgain->{
if(notAgain)
GlobalUserPreferences.optOutOfPreReplySheet(GlobalUserPreferences.PreReplySheetType.OLD_POST, null, null);
proceed.run();
}, status).show();
}else{
proceed.run();
}
}
protected void onModifyItemViewHolder(BindableViewHolder<StatusDisplayItem> holder){}
protected class DisplayItemsAdapter extends UsableRecyclerView.Adapter<BindableViewHolder<StatusDisplayItem>> implements ImageLoaderRecyclerAdapter{

View File

@@ -210,11 +210,13 @@ public class ThreadFragment extends StatusListFragment{
}
private void openReply(){
Bundle args=new Bundle();
args.putString("account", accountID);
args.putParcelable("replyTo", Parcels.wrap(mainStatus));
args.putBoolean("fromThreadFragment", true);
Nav.go(getActivity(), ComposeFragment.class, args);
maybeShowPreReplySheet(mainStatus, ()->{
Bundle args=new Bundle();
args.putString("account", accountID);
args.putParcelable("replyTo", Parcels.wrap(mainStatus));
args.putBoolean("fromThreadFragment", true);
Nav.go(getActivity(), ComposeFragment.class, args);
});
}
public int getSnackbarOffset(){

View File

@@ -1,10 +1,9 @@
package org.joinmastodon.android.fragments.settings;
import android.content.Context;
import android.content.SharedPreferences;
import android.os.Bundle;
import android.widget.Toast;
import org.joinmastodon.android.MastodonApp;
import org.joinmastodon.android.GlobalUserPreferences;
import org.joinmastodon.android.api.session.AccountActivationInfo;
import org.joinmastodon.android.api.session.AccountSession;
import org.joinmastodon.android.api.session.AccountSessionManager;
@@ -28,7 +27,8 @@ public class SettingsDebugFragment extends BaseSettingsFragment<Void>{
new ListItem<>("Test email confirmation flow", null, this::onTestEmailConfirmClick),
selfUpdateItem=new ListItem<>("Force self-update", null, this::onForceSelfUpdateClick),
resetUpdateItem=new ListItem<>("Reset self-updater", null, this::onResetUpdaterClick),
new ListItem<>("Reset search info banners", null, this::onResetDiscoverBannersClick)
new ListItem<>("Reset search info banners", null, this::onResetDiscoverBannersClick),
new ListItem<>("Reset pre-reply sheets", null, this::onResetPreReplySheetsClick)
));
if(!GithubSelfUpdater.needSelfUpdating()){
resetUpdateItem.isEnabled=selfUpdateItem.isEnabled=false;
@@ -65,6 +65,11 @@ public class SettingsDebugFragment extends BaseSettingsFragment<Void>{
restartUI();
}
private void onResetPreReplySheetsClick(ListItem<?> item){
GlobalUserPreferences.resetPreReplySheets();
Toast.makeText(getActivity(), "Pre-reply sheets were reset", Toast.LENGTH_SHORT).show();
}
private void restartUI(){
Bundle args=new Bundle();
args.putString("account", accountID);

View File

@@ -5,4 +5,8 @@ package org.joinmastodon.android.model;
*/
public interface DisplayItemsParent{
String getID();
default String getAccountID(){
return null;
}
}

View File

@@ -34,6 +34,11 @@ public class Notification extends BaseModel implements DisplayItemsParent{
return id;
}
@Override
public String getAccountID(){
return status!=null ? account.id : null;
}
public enum Type{
@SerializedName("follow")
FOLLOW,

View File

@@ -34,10 +34,18 @@ public class SearchResult extends BaseModel implements DisplayItemsParent{
generateID();
}
@Override
public String getID(){
return id;
}
@Override
public String getAccountID(){
if(type==Type.STATUS)
return status.getAccountID();
return null;
}
@Override
public void postprocess() throws ObjectValidationException{
super.postprocess();

View File

@@ -143,6 +143,11 @@ public class Status extends BaseModel implements DisplayItemsParent{
return id;
}
@Override
public String getAccountID(){
return getContentStatus().account.id;
}
public void update(StatusCountersUpdatedEvent ev){
favouritesCount=ev.favorites;
reblogsCount=ev.reblogs;

View File

@@ -0,0 +1,123 @@
package org.joinmastodon.android.ui;
import android.annotation.SuppressLint;
import android.content.Context;
import android.graphics.Typeface;
import android.text.SpannableStringBuilder;
import android.text.TextUtils;
import android.text.style.AbsoluteSizeSpan;
import android.text.style.ForegroundColorSpan;
import android.text.style.TextAppearanceSpan;
import android.view.Gravity;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
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.model.AccountField;
import org.joinmastodon.android.ui.text.HtmlParser;
import org.joinmastodon.android.ui.utils.UiUtils;
import androidx.annotation.NonNull;
import me.grishka.appkit.imageloader.ViewImageLoader;
import me.grishka.appkit.imageloader.requests.UrlImageLoaderRequest;
import me.grishka.appkit.utils.V;
public class NonMutualPreReplySheet extends PreReplySheet{
@SuppressLint("DefaultLocale")
public NonMutualPreReplySheet(@NonNull Context context, ResultListener resultListener, Account account){
super(context, resultListener);
icon.setImageResource(R.drawable.ic_waving_hand_24px);
title.setText(R.string.non_mutual_sheet_title);
text.setText(R.string.non_mutual_sheet_text);
LinearLayout userInfo=new LinearLayout(context);
userInfo.setOrientation(LinearLayout.HORIZONTAL);
userInfo.setBackgroundResource(R.drawable.bg_user_info);
UiUtils.setAllPaddings(userInfo, 12);
ImageView ava=new ImageView(context);
ava.setScaleType(ImageView.ScaleType.CENTER_CROP);
ava.setImportantForAccessibility(View.IMPORTANT_FOR_ACCESSIBILITY_NO);
ava.setOutlineProvider(OutlineProviders.roundedRect(12));
ava.setClipToOutline(true);
ava.setForeground(context.getResources().getDrawable(R.drawable.fg_user_info_ava, context.getTheme()));
userInfo.addView(ava, UiUtils.makeLayoutParams(56, 56, 0, 0, 12, 0));
ViewImageLoader.loadWithoutAnimation(ava, context.getResources().getDrawable(R.drawable.image_placeholder), new UrlImageLoaderRequest(account.avatarStatic, V.dp(56), V.dp(56)));
LinearLayout nameAndFields=new LinearLayout(context);
nameAndFields.setOrientation(LinearLayout.VERTICAL);
nameAndFields.setMinimumHeight(V.dp(56));
nameAndFields.setGravity(Gravity.CENTER_VERTICAL);
userInfo.addView(nameAndFields, new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT));
TextView name=new TextView(context);
name.setSingleLine();
name.setEllipsize(TextUtils.TruncateAt.END);
name.setTextAppearance(R.style.m3_title_medium);
name.setTextColor(UiUtils.getThemeColor(context, R.attr.colorM3OnSurface));
name.setText(account.displayName);
name.setGravity(Gravity.CENTER_VERTICAL | Gravity.START);
nameAndFields.addView(name, new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, V.dp(24)));
if(account.fields!=null && !account.fields.isEmpty()){
for(AccountField field:account.fields){
LinearLayout fieldView=new LinearLayout(context);
fieldView.setOrientation(LinearLayout.HORIZONTAL);
TextView key=new TextView(context);
key.setTextAppearance(R.style.m3_body_medium);
key.setTextColor(UiUtils.getThemeColor(context, R.attr.colorM3Secondary));
key.setSingleLine();
key.setEllipsize(TextUtils.TruncateAt.END);
key.setText(field.name);
key.setGravity(Gravity.CENTER_VERTICAL | Gravity.START);
key.setPaddingRelative(0, 0, V.dp(8), 0);
fieldView.addView(key, new LinearLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.MATCH_PARENT));
TextView value=new TextView(context);
value.setTextAppearance(R.style.m3_body_medium);
value.setTypeface(Typeface.create("sans-serif-medium", Typeface.NORMAL));
value.setTextColor(UiUtils.getThemeColor(context, R.attr.colorM3Secondary));
value.setSingleLine();
value.setEllipsize(TextUtils.TruncateAt.END);
value.setText(HtmlParser.stripAndRemoveInvisibleSpans(field.value));
value.setGravity(Gravity.CENTER_VERTICAL | Gravity.END);
fieldView.addView(value, new LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.MATCH_PARENT, 1f));
nameAndFields.addView(fieldView, new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, V.dp(20)));
}
}else{
TextView username=new TextView(context);
username.setTextAppearance(R.style.m3_body_medium);
username.setTextColor(UiUtils.getThemeColor(context, R.attr.colorM3Secondary));
username.setSingleLine();
username.setEllipsize(TextUtils.TruncateAt.END);
username.setText(account.getDisplayUsername());
username.setGravity(Gravity.CENTER_VERTICAL | Gravity.START);
nameAndFields.addView(username, new ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, V.dp(20)));
}
contentWrap.addView(userInfo, UiUtils.makeLayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT, 0, 0, 0, 8));
for(int i=0;i<3;i++){
View item=context.getSystemService(LayoutInflater.class).inflate(R.layout.item_other_numbered_rule, contentWrap, false);
TextView number=item.findViewById(R.id.number);
number.setText(String.format("%d", i+1));
TextView title=item.findViewById(R.id.title);
TextView text=item.findViewById(R.id.text);
title.setText(switch(i){
case 0 -> R.string.non_mutual_title1;
case 1 -> R.string.non_mutual_title2;
case 2 -> R.string.non_mutual_title3;
default -> throw new IllegalStateException("Unexpected value: "+i);
});
text.setText(switch(i){
case 0 -> R.string.non_mutual_text1;
case 1 -> R.string.non_mutual_text2;
case 2 -> R.string.non_mutual_text3;
default -> throw new IllegalStateException("Unexpected value: "+i);
});
contentWrap.addView(item);
}
}
}

View File

@@ -0,0 +1,23 @@
package org.joinmastodon.android.ui;
import android.content.Context;
import org.joinmastodon.android.R;
import org.joinmastodon.android.model.Status;
import java.time.ZoneId;
import java.time.ZonedDateTime;
import java.time.temporal.ChronoUnit;
import androidx.annotation.NonNull;
public class OldPostPreReplySheet extends PreReplySheet{
public OldPostPreReplySheet(@NonNull Context context, ResultListener resultListener, Status status){
super(context, resultListener);
int months=(int)status.createdAt.atZone(ZoneId.systemDefault()).until(ZonedDateTime.now(), ChronoUnit.MONTHS);
String monthsStr=months>24 ? context.getString(R.string.more_than_two_years) : context.getResources().getQuantityString(R.plurals.x_months, months, months);
title.setText(context.getString(R.string.old_post_sheet_title, monthsStr));
text.setText(R.string.old_post_sheet_text);
icon.setImageResource(R.drawable.ic_history_24px);
}
}

View File

@@ -0,0 +1,54 @@
package org.joinmastodon.android.ui;
import android.content.Context;
import android.graphics.drawable.ColorDrawable;
import android.view.LayoutInflater;
import android.view.View;
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.ui.utils.UiUtils;
import androidx.annotation.NonNull;
import me.grishka.appkit.views.BottomSheet;
public abstract class PreReplySheet extends BottomSheet{
protected ImageView icon;
protected TextView title, text;
protected Button gotItButton, dontRemindButton;
protected LinearLayout contentWrap;
public PreReplySheet(@NonNull Context context, ResultListener resultListener){
super(context);
View content=context.getSystemService(LayoutInflater.class).inflate(R.layout.sheet_pre_reply, null);
setContentView(content);
setNavigationBarBackground(new ColorDrawable(UiUtils.alphaBlendColors(UiUtils.getThemeColor(context, R.attr.colorM3Surface),
UiUtils.getThemeColor(context, R.attr.colorM3Primary), 0.05f)), !UiUtils.isDarkTheme());
icon=findViewById(R.id.icon);
title=findViewById(R.id.title);
text=findViewById(R.id.text);
gotItButton=findViewById(R.id.btn_got_it);
dontRemindButton=findViewById(R.id.btn_dont_remind_again);
contentWrap=findViewById(R.id.content_wrap);
gotItButton.setOnClickListener(v->{
dismiss();
resultListener.onButtonClicked(false);
});
dontRemindButton.setOnClickListener(v->{
dismiss();
resultListener.onButtonClicked(true);
});
}
@FunctionalInterface
public interface ResultListener{
void onButtonClicked(boolean notAgain);
}
}

View File

@@ -129,10 +129,12 @@ public class FooterStatusDisplayItem extends StatusDisplayItem{
}
private void onReplyClick(View 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);
item.parentFragment.maybeShowPreReplySheet(item.status, ()->{
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 void onBoostClick(View v){

View File

@@ -115,8 +115,6 @@ public class HeaderStatusDisplayItem extends StatusDisplayItem{
private final TextView name, timeAndUsername, extraText;
private final ImageView avatar, more;
private final PopupMenu optionsMenu;
private Relationship relationship;
private APIRequest<?> currentRelationshipRequest;
public Holder(Activity activity, ViewGroup parent){
this(activity, R.layout.display_item_header, parent);
@@ -140,6 +138,7 @@ 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){
final Bundle args=new Bundle();
@@ -192,7 +191,7 @@ public class HeaderStatusDisplayItem extends StatusDisplayItem{
else
progress.dismiss();
}, rel->{
relationship=rel;
item.parentFragment.putRelationship(account.id, 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.bookmark){
@@ -235,10 +234,6 @@ public class HeaderStatusDisplayItem extends StatusDisplayItem{
more.setVisibility(item.inset ? View.GONE : View.VISIBLE);
avatar.setClickable(!item.inset);
avatar.setContentDescription(item.parentFragment.getString(R.string.avatar_description, item.user.acct));
if(currentRelationshipRequest!=null){
currentRelationshipRequest.cancel();
}
relationship=null;
}
@Override
@@ -272,31 +267,13 @@ public class HeaderStatusDisplayItem extends StatusDisplayItem{
private void onMoreClick(View v){
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;
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();

View File

@@ -204,7 +204,7 @@ public class HtmlParser{
Document doc=Jsoup.parseBodyFragment(html);
doc.body().select("span.invisible").remove();
Cleaner cleaner=new Cleaner(Safelist.none());
return cleaner.clean(doc).body().html();
return cleaner.clean(doc).body().text();
}
public static CharSequence parseLinks(String text){

View File

@@ -835,4 +835,18 @@ public class UiUtils{
Toast.makeText(context, R.string.text_copied, Toast.LENGTH_SHORT).show();
}
}
public static void setAllPaddings(View view, int paddingDp){
int pad=V.dp(paddingDp);
view.setPadding(pad, pad, pad, pad);
}
public static ViewGroup.MarginLayoutParams makeLayoutParams(int width, int height, int marginStart, int marginTop, int marginEnd, int marginBottom){
ViewGroup.MarginLayoutParams lp=new ViewGroup.MarginLayoutParams(width>0 ? V.dp(width) : width, height>0 ? V.dp(height) : height);
lp.topMargin=V.dp(marginTop);
lp.bottomMargin=V.dp(marginBottom);
lp.setMarginStart(V.dp(marginStart));
lp.setMarginEnd(V.dp(marginEnd));
return lp;
}
}