diff --git a/mastodon/build.gradle b/mastodon/build.gradle index d3391cfac..84594b801 100644 --- a/mastodon/build.gradle +++ b/mastodon/build.gradle @@ -17,7 +17,7 @@ android { minSdk 23 targetSdk 33 versionCode 104 - versionName "2.1.4+fork.104.moshinda" + versionName "2.2.4+fork.104.moshinda" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" resourceConfigurations += ['ar-rSA', 'ar-rDZ', 'be-rBY', 'bn-rBD', 'bs-rBA', 'ca-rES', 'cs-rCZ', 'da-rDK', 'de-rDE', 'el-rGR', 'es-rES', 'eu-rES', 'fa-rIR', 'fi-rFI', 'fil-rPH', 'fr-rFR', 'ga-rIE', 'gd-rGB', 'gl-rES', 'hi-rIN', 'hr-rHR', 'hu-rHU', 'hy-rAM', 'ig-rNG', 'in-rID', 'is-rIS', 'it-rIT', 'iw-rIL', 'ja-rJP', 'kab', 'ko-rKR', 'my-rMM', 'nl-rNL', 'no-rNO', 'oc-rFR', 'pl-rPL', 'pt-rBR', 'pt-rPT', 'ro-rRO', 'ru-rRU', 'si-rLK', 'sl-rSI', 'sv-rSE', 'th-rTH', 'tr-rTR', 'uk-rUA', 'ur-rIN', 'vi-rVN', 'zh-rCN', 'zh-rTW'] } @@ -142,7 +142,7 @@ dependencies { implementation 'me.grishka.litex:viewpager:1.0.0' implementation 'me.grishka.litex:viewpager2:1.0.0' implementation 'me.grishka.litex:palette:1.0.0' - implementation 'me.grishka.appkit:appkit:1.2.14' + implementation 'me.grishka.appkit:appkit:1.2.16' implementation 'com.google.code.gson:gson:2.9.0' implementation 'org.jsoup:jsoup:1.14.3' implementation 'com.squareup:otto:1.3.8' diff --git a/mastodon/src/androidTest/java/org/joinmastodon/android/utils/StatusFilterPredicateTest.java b/mastodon/src/androidTest/java/org/joinmastodon/android/utils/StatusFilterPredicateTest.java index 8ad1ff689..87235de8f 100644 --- a/mastodon/src/androidTest/java/org/joinmastodon/android/utils/StatusFilterPredicateTest.java +++ b/mastodon/src/androidTest/java/org/joinmastodon/android/utils/StatusFilterPredicateTest.java @@ -4,6 +4,9 @@ import static org.joinmastodon.android.model.FilterAction.*; import static org.joinmastodon.android.model.FilterContext.*; import static org.junit.Assert.*; +import android.graphics.drawable.ColorDrawable; + +import org.joinmastodon.android.model.Attachment; import org.joinmastodon.android.model.LegacyFilter; import org.joinmastodon.android.model.Status; import org.junit.Test; @@ -32,11 +35,11 @@ public class StatusFilterPredicateTest { warnMeFilter.filterAction = WARN; warnMeFilter.context = EnumSet.of(PUBLIC, HOME); - noAltText.mediaAttachments = Attachment.createFakeAttachments("fakeurl", new ColorDrawable()); - withAltText.mediaAttachments = Attachment.createFakeAttachments("fakeurl", new ColorDrawable()); - for (Attachment mediaAttachment : withAltText.mediaAttachments) { - mediaAttachment.description = "Alt Text"; - } +// noAltText.mediaAttachments = Attachment.createFakeAttachments("fakeurl", new ColorDrawable()); +// withAltText.mediaAttachments = Attachment.createFakeAttachments("fakeurl", new ColorDrawable()); +// for (Attachment mediaAttachment : withAltText.mediaAttachments) { +// mediaAttachment.description = "Alt Text"; +// } } @Test diff --git a/mastodon/src/main/java/org/joinmastodon/android/ExternalShareActivity.java b/mastodon/src/main/java/org/joinmastodon/android/ExternalShareActivity.java index 382456256..9c2aef514 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/ExternalShareActivity.java +++ b/mastodon/src/main/java/org/joinmastodon/android/ExternalShareActivity.java @@ -13,7 +13,7 @@ import org.joinmastodon.android.api.MastodonAPIRequest; import org.joinmastodon.android.api.session.AccountSession; import org.joinmastodon.android.api.session.AccountSessionManager; import org.joinmastodon.android.fragments.ComposeFragment; -import org.joinmastodon.android.ui.AccountSwitcherSheet; +import org.joinmastodon.android.ui.sheets.AccountSwitcherSheet; import org.joinmastodon.android.ui.utils.UiUtils; import org.jsoup.internal.StringUtil; diff --git a/mastodon/src/main/java/org/joinmastodon/android/GlobalUserPreferences.java b/mastodon/src/main/java/org/joinmastodon/android/GlobalUserPreferences.java index 389e6eb81..6f14482dc 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/GlobalUserPreferences.java +++ b/mastodon/src/main/java/org/joinmastodon/android/GlobalUserPreferences.java @@ -28,6 +28,9 @@ import java.util.HashSet; import java.util.Map; import java.util.Set; +import org.joinmastodon.android.api.session.AccountSessionManager; +import org.joinmastodon.android.model.Account; + public class GlobalUserPreferences{ private static final String TAG="GlobalUserPreferences"; @@ -84,6 +87,11 @@ 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 T fromJson(String json, Type type, T orElse){ if(json==null) return orElse; try{ @@ -236,12 +244,41 @@ 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 enum ThemePreference{ AUTO, LIGHT, DARK } + public enum PreReplySheetType{ + OLD_POST, + NON_MUTUAL + } + public enum AutoRevealMode { NEVER, THREADS, @@ -306,5 +343,4 @@ public class GlobalUserPreferences{ } //endregion - } diff --git a/mastodon/src/main/java/org/joinmastodon/android/MainActivity.java b/mastodon/src/main/java/org/joinmastodon/android/MainActivity.java index 0625962ae..979a160fe 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/MainActivity.java +++ b/mastodon/src/main/java/org/joinmastodon/android/MainActivity.java @@ -12,6 +12,7 @@ import android.content.pm.PackageManager; import android.graphics.Bitmap; import android.net.Uri; import android.net.Uri; +import android.os.BadParcelableException; import android.os.Build; import android.os.Bundle; import android.provider.MediaStore; @@ -131,11 +132,11 @@ public class MainActivity extends FragmentStackActivity implements ProvidesAssis session=AccountSessionManager.get(accountID); if(session==null || !session.activated) return; - openSearchQuery(uri.toString(), session.getID(), R.string.opening_link, false); + openSearchQuery(uri.toString(), session.getID(), R.string.opening_link, false, null); } - public void openSearchQuery(String q, String accountID, int progressText, boolean fromSearch){ - new GetSearchResults(q, null, true, null, 0, 0) + public void openSearchQuery(String q, String accountID, int progressText, boolean fromSearch, GetSearchResults.Type type){ + new GetSearchResults(q, type, true, null, 0, 0) .setCallback(new Callback<>(){ @Override public void onSuccess(SearchResults result){ @@ -334,8 +335,14 @@ public class MainActivity extends FragmentStackActivity implements ProvidesAssis Fragment fragment=session.activated ? new HomeFragment() : new AccountActivationFragment(); fragment.setArguments(args); if(fromNotification && hasNotification){ - Notification notification=Parcels.unwrap(intent.getParcelableExtra("notification")); - showFragmentForNotification(notification, session.getID()); + // Parcelables might not be compatible across app versions so this protects against possible crashes + // when a notification was received, then the app was updated, and then the user opened the notification + try{ + Notification notification=Parcels.unwrap(intent.getParcelableExtra("notification")); + showFragmentForNotification(notification, session.getID()); + }catch(BadParcelableException x){ + Log.w(TAG, x); + } } else if (intent.getBooleanExtra("compose", false)){ showCompose(); } else if (Intent.ACTION_VIEW.equals(intent.getAction())){ diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/CacheController.java b/mastodon/src/main/java/org/joinmastodon/android/api/CacheController.java index 47b55c4c1..a904ac0a0 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/api/CacheController.java +++ b/mastodon/src/main/java/org/joinmastodon/android/api/CacheController.java @@ -9,21 +9,32 @@ import android.os.Handler; import android.os.Looper; import android.util.Log; +import com.google.gson.reflect.TypeToken; + import org.joinmastodon.android.BuildConfig; import org.joinmastodon.android.MastodonApp; +import org.joinmastodon.android.api.requests.lists.GetLists; import org.joinmastodon.android.api.requests.notifications.GetNotifications; import org.joinmastodon.android.api.requests.timelines.GetHomeTimeline; import org.joinmastodon.android.api.session.AccountSessionManager; import org.joinmastodon.android.model.CacheablePaginatedResponse; import org.joinmastodon.android.model.FilterContext; +import org.joinmastodon.android.model.FollowList; import org.joinmastodon.android.model.Instance; import org.joinmastodon.android.model.Notification; import org.joinmastodon.android.model.PaginatedResponse; import org.joinmastodon.android.model.SearchResult; import org.joinmastodon.android.model.Status; +import org.joinmastodon.android.ui.utils.UiUtils; +import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; import java.io.IOException; +import java.io.InputStreamReader; +import java.io.OutputStreamWriter; import java.util.ArrayList; +import java.util.Comparator; import java.util.EnumSet; import java.util.List; import java.util.Objects; @@ -44,6 +55,7 @@ public class CacheController{ private final Runnable databaseCloseRunnable=this::closeDatabase; private boolean loadingNotifications; private final ArrayList>>> pendingNotificationsCallbacks=new ArrayList<>(); + private List lists; private static final int POST_FLAG_GAP_AFTER=1; @@ -348,6 +360,99 @@ public class CacheController{ }, 0); } + public void reloadLists(Callback> callback){ + new GetLists() + .setCallback(new Callback<>(){ + @Override + public void onSuccess(List result){ + result.sort(Comparator.comparing(l->l.title)); + lists=result; + if(callback!=null) + callback.onSuccess(result); + writeListsToFile(); + } + + @Override + public void onError(ErrorResponse error){ + if(callback!=null) + callback.onError(error); + } + }) + .exec(accountID); + } + + private List loadListsFromFile(){ + File file=getListsFile(); + if(!file.exists()) + return null; + try(InputStreamReader in=new InputStreamReader(new FileInputStream(file))){ + return MastodonAPIController.gson.fromJson(in, new TypeToken>(){}.getType()); + }catch(Exception x){ + Log.w(TAG, "failed to read lists from cache file", x); + return null; + } + } + + private void writeListsToFile(){ + databaseThread.postRunnable(()->{ + try(OutputStreamWriter out=new OutputStreamWriter(new FileOutputStream(getListsFile()))){ + MastodonAPIController.gson.toJson(lists, out); + }catch(IOException x){ + Log.w(TAG, "failed to write lists to cache file", x); + } + }, 0); + } + + public void getLists(Callback> callback){ + if(lists!=null){ + if(callback!=null) + callback.onSuccess(lists); + return; + } + databaseThread.postRunnable(()->{ + List lists=loadListsFromFile(); + if(lists!=null){ + this.lists=lists; + if(callback!=null) + uiHandler.post(()->callback.onSuccess(lists)); + return; + } + reloadLists(callback); + }, 0); + } + + public File getListsFile(){ + return new File(MastodonApp.context.getFilesDir(), "lists_"+accountID+".json"); + } + + public void addList(FollowList list){ + if(lists==null) + return; + lists.add(list); + lists.sort(Comparator.comparing(l->l.title)); + writeListsToFile(); + } + + public void deleteList(String id){ + if(lists==null) + return; + lists.removeIf(l->l.id.equals(id)); + writeListsToFile(); + } + + public void updateList(FollowList list){ + if(lists==null) + return; + for(int i=0;il.title)); + writeListsToFile(); + break; + } + } + } + private class DatabaseHelper extends SQLiteOpenHelper{ public DatabaseHelper(){ diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/MastodonAPIController.java b/mastodon/src/main/java/org/joinmastodon/android/api/MastodonAPIController.java index 522f9a62c..23be5d4b9 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/api/MastodonAPIController.java +++ b/mastodon/src/main/java/org/joinmastodon/android/api/MastodonAPIController.java @@ -54,7 +54,9 @@ public class MastodonAPIController{ .create(); private static WorkerThread thread=new WorkerThread("MastodonAPIController"); private static OkHttpClient httpClient=new OkHttpClient.Builder() - .readTimeout(30, TimeUnit.SECONDS) + .connectTimeout(60, TimeUnit.SECONDS) + .writeTimeout(60, TimeUnit.SECONDS) + .readTimeout(60, TimeUnit.SECONDS) .build(); private AccountSession session; @@ -122,15 +124,15 @@ public class MastodonAPIController{ } if(BuildConfig.DEBUG) - Log.d(TAG, "["+(session==null ? "no-auth" : session.getID())+"] Sending request: "+hreq); + Log.d(TAG, logTag(session)+"Sending request: "+hreq); call.enqueue(new Callback(){ @Override public void onFailure(@NonNull Call call, @NonNull IOException e){ - if(call.isCanceled()) + if(req.canceled) return; if(BuildConfig.DEBUG) - Log.w(TAG, "["+(session==null ? "no-auth" : session.getID())+"] "+hreq+" failed", e); + Log.w(TAG, logTag(session)+""+hreq+" failed", e); synchronized(req){ req.okhttpCall=null; } @@ -139,10 +141,10 @@ public class MastodonAPIController{ @Override public void onResponse(@NonNull Call call, @NonNull Response response) throws IOException{ - if(call.isCanceled()) + if(req.canceled) return; if(BuildConfig.DEBUG) - Log.d(TAG, "["+(session==null ? "no-auth" : session.getID())+"] "+hreq+" received response: "+response); + Log.d(TAG, logTag(session)+hreq+" received response: "+response); synchronized(req){ req.okhttpCall=null; } @@ -153,7 +155,7 @@ public class MastodonAPIController{ try{ if(BuildConfig.DEBUG){ JsonElement respJson=JsonParser.parseReader(reader); - Log.d(TAG, "["+(session==null ? "no-auth" : session.getID())+"] response body: "+respJson); + Log.d(TAG, logTag(session)+"response body: "+respJson); if(req.respTypeToken!=null) respObj=gson.fromJson(respJson, req.respTypeToken.getType()); else if(req.respClass!=null) @@ -175,7 +177,7 @@ public class MastodonAPIController{ return; } if(BuildConfig.DEBUG) - Log.w(TAG, "["+(session==null ? "no-auth" : session.getID())+"] "+response+" error parsing or reading body", x); + Log.w(TAG, logTag(session)+response+" error parsing or reading body", x); req.onError(x.getLocalizedMessage(), response.code(), x); return; } @@ -184,19 +186,19 @@ public class MastodonAPIController{ req.validateAndPostprocessResponse(respObj, response); }catch(IOException x){ if(BuildConfig.DEBUG) - Log.w(TAG, "["+(session==null ? "no-auth" : session.getID())+"] "+response+" error post-processing or validating response", x); + Log.w(TAG, logTag(session)+response+" error post-processing or validating response", x); req.onError(x.getLocalizedMessage(), response.code(), x); return; } if(BuildConfig.DEBUG) - Log.d(TAG, "["+(session==null ? "no-auth" : session.getID())+"] "+response+" parsed successfully: "+respObj); + Log.d(TAG, logTag(session)+response+" parsed successfully: "+respObj); req.onSuccess(respObj); }else{ try{ JsonObject error=JsonParser.parseReader(reader).getAsJsonObject(); - Log.w(TAG, "["+(session==null ? "no-auth" : session.getID())+"] "+response+" received error: "+error); + Log.w(TAG, logTag(session)+response+" received error: "+error); if(error.has("details")){ MastodonDetailedErrorResponse err=new MastodonDetailedErrorResponse(error.get("error").getAsString(), response.code(), null); HashMap> details=new HashMap<>(); @@ -231,7 +233,7 @@ public class MastodonAPIController{ }); }catch(Exception x){ if(BuildConfig.DEBUG) - Log.w(TAG, "["+(session==null ? "no-auth" : session.getID())+"] error creating and sending http request", x); + Log.w(TAG, logTag(session)+"error creating and sending http request", x); req.onError(x.getLocalizedMessage(), 0, x); } }, 0); @@ -244,4 +246,8 @@ public class MastodonAPIController{ public static OkHttpClient getHttpClient(){ return httpClient; } + + private static String logTag(AccountSession session){ + return "["+(session==null ? "no-auth" : session.getID())+"] "; + } } diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/MastodonAPIRequest.java b/mastodon/src/main/java/org/joinmastodon/android/api/MastodonAPIRequest.java index 09cb7ca60..99b0beb48 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/api/MastodonAPIRequest.java +++ b/mastodon/src/main/java/org/joinmastodon/android/api/MastodonAPIRequest.java @@ -180,6 +180,8 @@ public abstract class MastodonAPIRequest extends APIRequest{ } public RequestBody getRequestBody() throws IOException{ + if(requestBody instanceof RequestBody rb) + return rb; return requestBody==null ? null : new JsonObjectRequestBody(requestBody); } diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/requests/accounts/CheckInviteLink.java b/mastodon/src/main/java/org/joinmastodon/android/api/requests/accounts/CheckInviteLink.java new file mode 100644 index 000000000..136200548 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/api/requests/accounts/CheckInviteLink.java @@ -0,0 +1,22 @@ +package org.joinmastodon.android.api.requests.accounts; + +import org.joinmastodon.android.api.MastodonAPIRequest; +import org.joinmastodon.android.api.RequiredField; +import org.joinmastodon.android.model.BaseModel; + +public class CheckInviteLink extends MastodonAPIRequest{ + public CheckInviteLink(String path){ + super(HttpMethod.GET, path, Response.class); + addHeader("Accept", "application/json"); + } + + @Override + protected String getPathPrefix(){ + return ""; + } + + public static class Response extends BaseModel{ + @RequiredField + public String inviteCode; + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/requests/accounts/GetAccountLists.java b/mastodon/src/main/java/org/joinmastodon/android/api/requests/accounts/GetAccountLists.java new file mode 100644 index 000000000..55e03fb31 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/api/requests/accounts/GetAccountLists.java @@ -0,0 +1,14 @@ +package org.joinmastodon.android.api.requests.accounts; + +import com.google.gson.reflect.TypeToken; + +import org.joinmastodon.android.api.MastodonAPIRequest; +import org.joinmastodon.android.model.FollowList; + +import java.util.List; + +public class GetAccountLists extends MastodonAPIRequest>{ + public GetAccountLists(String id){ + super(HttpMethod.GET, "/accounts/"+id+"/lists", new TypeToken<>(){}); + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/requests/accounts/RegisterAccount.java b/mastodon/src/main/java/org/joinmastodon/android/api/requests/accounts/RegisterAccount.java index 622145237..df7915bb5 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/api/requests/accounts/RegisterAccount.java +++ b/mastodon/src/main/java/org/joinmastodon/android/api/requests/accounts/RegisterAccount.java @@ -4,22 +4,23 @@ import org.joinmastodon.android.api.MastodonAPIRequest; import org.joinmastodon.android.model.Token; public class RegisterAccount extends MastodonAPIRequest{ - public RegisterAccount(String username, String email, String password, String locale, String reason, String timezone){ + public RegisterAccount(String username, String email, String password, String locale, String reason, String timezone, String inviteCode){ super(HttpMethod.POST, "/accounts", Token.class); - setRequestBody(new Body(username, email, password, locale, reason, timezone)); + setRequestBody(new Body(username, email, password, locale, reason, timezone, inviteCode)); } private static class Body{ - public String username, email, password, locale, reason, timeZone; + public String username, email, password, locale, reason, timeZone, inviteCode; public boolean agreement=true; - public Body(String username, String email, String password, String locale, String reason, String timeZone){ + public Body(String username, String email, String password, String locale, String reason, String timeZone, String inviteCode){ this.username=username; this.email=email; this.password=password; this.locale=locale; this.reason=reason; this.timeZone=timeZone; + this.inviteCode=inviteCode; } } } diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/requests/accounts/SearchAccounts.java b/mastodon/src/main/java/org/joinmastodon/android/api/requests/accounts/SearchAccounts.java new file mode 100644 index 000000000..6bfb87e4c --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/api/requests/accounts/SearchAccounts.java @@ -0,0 +1,23 @@ +package org.joinmastodon.android.api.requests.accounts; + +import com.google.gson.reflect.TypeToken; + +import org.joinmastodon.android.api.MastodonAPIRequest; +import org.joinmastodon.android.model.Account; + +import java.util.List; + +public class SearchAccounts extends MastodonAPIRequest>{ + public SearchAccounts(String q, int limit, int offset, boolean resolve, boolean following){ + super(HttpMethod.GET, "/accounts/search", new TypeToken<>(){}); + addQueryParameter("q", q); + if(limit>0) + addQueryParameter("limit", limit+""); + if(offset>0) + addQueryParameter("offset", offset+""); + if(resolve) + addQueryParameter("resolve", "true"); + if(following) + addQueryParameter("following", "true"); + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/requests/accounts/UpdateAccountCredentials.java b/mastodon/src/main/java/org/joinmastodon/android/api/requests/accounts/UpdateAccountCredentials.java index 5724a933b..37f52f83b 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/api/requests/accounts/UpdateAccountCredentials.java +++ b/mastodon/src/main/java/org/joinmastodon/android/api/requests/accounts/UpdateAccountCredentials.java @@ -22,6 +22,7 @@ public class UpdateAccountCredentials extends MastodonAPIRequest{ private Uri avatar, cover; private File avatarFile, coverFile; private List fields; + private Boolean discoverable, indexable; public UpdateAccountCredentials(String displayName, String bio, Uri avatar, Uri cover, List fields){ super(HttpMethod.PATCH, "/accounts/update_credentials", Account.class); @@ -41,6 +42,12 @@ public class UpdateAccountCredentials extends MastodonAPIRequest{ this.fields=fields; } + public UpdateAccountCredentials setDiscoverableIndexable(boolean discoverable, boolean indexable){ + this.discoverable=discoverable; + this.indexable=indexable; + return this; + } + @Override public RequestBody getRequestBody() throws IOException{ MultipartBody.Builder bldr=new MultipartBody.Builder() @@ -58,15 +65,21 @@ public class UpdateAccountCredentials extends MastodonAPIRequest{ }else if(coverFile!=null){ bldr.addFormDataPart("header", coverFile.getName(), new ResizedImageRequestBody(Uri.fromFile(coverFile), 1500*500, null)); } - if(fields.isEmpty()){ - bldr.addFormDataPart("fields_attributes[0][name]", "").addFormDataPart("fields_attributes[0][value]", ""); - }else{ - int i=0; - for(AccountField field:fields){ - bldr.addFormDataPart("fields_attributes["+i+"][name]", field.name).addFormDataPart("fields_attributes["+i+"][value]", field.value); - i++; + if(fields!=null){ + if(fields.isEmpty()){ + bldr.addFormDataPart("fields_attributes[0][name]", "").addFormDataPart("fields_attributes[0][value]", ""); + }else{ + int i=0; + for(AccountField field:fields){ + bldr.addFormDataPart("fields_attributes["+i+"][name]", field.name).addFormDataPart("fields_attributes["+i+"][value]", field.value); + i++; + } } } + if(discoverable!=null) + bldr.addFormDataPart("discoverable", discoverable.toString()); + if(indexable!=null) + bldr.addFormDataPart("indexable", indexable.toString()); return bldr.build(); } diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/requests/filters/KeywordAttribute.java b/mastodon/src/main/java/org/joinmastodon/android/api/requests/filters/KeywordAttribute.java index d35a0f0fa..b2ed7f18b 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/api/requests/filters/KeywordAttribute.java +++ b/mastodon/src/main/java/org/joinmastodon/android/api/requests/filters/KeywordAttribute.java @@ -2,6 +2,9 @@ package org.joinmastodon.android.api.requests.filters; import com.google.gson.annotations.SerializedName; +import androidx.annotation.Keep; + +@Keep class KeywordAttribute{ public String id; @SerializedName("_destroy") diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/requests/lists/AddAccountsToList.java b/mastodon/src/main/java/org/joinmastodon/android/api/requests/lists/AddAccountsToList.java index f3db76322..29c1aacea 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/api/requests/lists/AddAccountsToList.java +++ b/mastodon/src/main/java/org/joinmastodon/android/api/requests/lists/AddAccountsToList.java @@ -1,17 +1,19 @@ package org.joinmastodon.android.api.requests.lists; -import org.joinmastodon.android.api.MastodonAPIRequest; -import java.util.List; +import org.joinmastodon.android.api.ResultlessMastodonAPIRequest; -public class AddAccountsToList extends MastodonAPIRequest { - public AddAccountsToList(String listId, List accountIds){ - super(HttpMethod.POST, "/lists/"+listId+"/accounts", Object.class); - Request req = new Request(); - req.accountIds = accountIds; - setRequestBody(req); - } +import java.nio.charset.StandardCharsets; +import java.util.Collection; - public static class Request{ - public List accountIds; - } +import okhttp3.FormBody; + +public class AddAccountsToList extends ResultlessMastodonAPIRequest{ + public AddAccountsToList(String listID, Collection accountIDs){ + super(HttpMethod.POST, "/lists/"+listID+"/accounts"); + FormBody.Builder builder=new FormBody.Builder(StandardCharsets.UTF_8); + for(String id:accountIDs){ + builder.add("account_ids[]", id); + } + setRequestBody(builder.build()); + } } diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/requests/lists/CreateList.java b/mastodon/src/main/java/org/joinmastodon/android/api/requests/lists/CreateList.java index 1ec4204e5..2c217a627 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/api/requests/lists/CreateList.java +++ b/mastodon/src/main/java/org/joinmastodon/android/api/requests/lists/CreateList.java @@ -1,21 +1,23 @@ package org.joinmastodon.android.api.requests.lists; import org.joinmastodon.android.api.MastodonAPIRequest; -import org.joinmastodon.android.model.ListTimeline; +import org.joinmastodon.android.model.FollowList; -public class CreateList extends MastodonAPIRequest { - public CreateList(String title, boolean exclusive, ListTimeline.RepliesPolicy repliesPolicy) { - super(HttpMethod.POST, "/lists", ListTimeline.class); - Request req = new Request(); - req.title = title; - req.exclusive = exclusive; - req.repliesPolicy = repliesPolicy; - setRequestBody(req); +public class CreateList extends MastodonAPIRequest{ + public CreateList(String title, FollowList.RepliesPolicy repliesPolicy, boolean exclusive){ + super(HttpMethod.POST, "/lists", FollowList.class); + setRequestBody(new Request(title, repliesPolicy, exclusive)); } - public static class Request { + private static class Request{ public String title; + public FollowList.RepliesPolicy repliesPolicy; public boolean exclusive; - public ListTimeline.RepliesPolicy repliesPolicy; + + public Request(String title, FollowList.RepliesPolicy repliesPolicy, boolean exclusive){ + this.title=title; + this.repliesPolicy=repliesPolicy; + this.exclusive=exclusive; + } } } diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/requests/lists/DeleteList.java b/mastodon/src/main/java/org/joinmastodon/android/api/requests/lists/DeleteList.java index 64620adac..716d9a5d8 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/api/requests/lists/DeleteList.java +++ b/mastodon/src/main/java/org/joinmastodon/android/api/requests/lists/DeleteList.java @@ -1,10 +1,9 @@ package org.joinmastodon.android.api.requests.lists; -import org.joinmastodon.android.api.MastodonAPIRequest; -import org.joinmastodon.android.model.ListTimeline; +import org.joinmastodon.android.api.ResultlessMastodonAPIRequest; -public class DeleteList extends MastodonAPIRequest { - public DeleteList(String id) { - super(HttpMethod.DELETE, "/lists/" + id, Object.class); +public class DeleteList extends ResultlessMastodonAPIRequest{ + public DeleteList(String id){ + super(HttpMethod.DELETE, "/lists/"+id); } } diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/requests/lists/GetList.java b/mastodon/src/main/java/org/joinmastodon/android/api/requests/lists/GetList.java index 19bda79ca..340af5523 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/api/requests/lists/GetList.java +++ b/mastodon/src/main/java/org/joinmastodon/android/api/requests/lists/GetList.java @@ -1,10 +1,10 @@ package org.joinmastodon.android.api.requests.lists; import org.joinmastodon.android.api.MastodonAPIRequest; -import org.joinmastodon.android.model.ListTimeline; +import org.joinmastodon.android.model.FollowList; -public class GetList extends MastodonAPIRequest { +public class GetList extends MastodonAPIRequest { public GetList(String id) { - super(HttpMethod.GET, "/lists/" + id, ListTimeline.class); + super(HttpMethod.GET, "/lists/" + id, FollowList.class); } } diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/requests/lists/GetListAccounts.java b/mastodon/src/main/java/org/joinmastodon/android/api/requests/lists/GetListAccounts.java new file mode 100644 index 000000000..1d54dc2d9 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/api/requests/lists/GetListAccounts.java @@ -0,0 +1,17 @@ +package org.joinmastodon.android.api.requests.lists; + +import android.text.TextUtils; + +import com.google.gson.reflect.TypeToken; + +import org.joinmastodon.android.api.requests.HeaderPaginationRequest; +import org.joinmastodon.android.model.Account; + +public class GetListAccounts extends HeaderPaginationRequest{ + public GetListAccounts(String listID, String maxID, int limit){ + super(HttpMethod.GET, "/lists/"+listID+"/accounts", new TypeToken<>(){}); + if(!TextUtils.isEmpty(maxID)) + addQueryParameter("max_id", maxID); + addQueryParameter("limit", String.valueOf(limit)); + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/requests/lists/GetLists.java b/mastodon/src/main/java/org/joinmastodon/android/api/requests/lists/GetLists.java index 61abeb03a..ab1cdad48 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/api/requests/lists/GetLists.java +++ b/mastodon/src/main/java/org/joinmastodon/android/api/requests/lists/GetLists.java @@ -3,11 +3,11 @@ package org.joinmastodon.android.api.requests.lists; import com.google.gson.reflect.TypeToken; import org.joinmastodon.android.api.MastodonAPIRequest; -import org.joinmastodon.android.model.ListTimeline; +import org.joinmastodon.android.model.FollowList; import java.util.List; -public class GetLists extends MastodonAPIRequest>{ +public class GetLists extends MastodonAPIRequest>{ public GetLists() { super(HttpMethod.GET, "/lists", new TypeToken<>(){}); } diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/requests/lists/RemoveAccountsFromList.java b/mastodon/src/main/java/org/joinmastodon/android/api/requests/lists/RemoveAccountsFromList.java index f285d54f6..20f20463f 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/api/requests/lists/RemoveAccountsFromList.java +++ b/mastodon/src/main/java/org/joinmastodon/android/api/requests/lists/RemoveAccountsFromList.java @@ -1,17 +1,19 @@ package org.joinmastodon.android.api.requests.lists; -import org.joinmastodon.android.api.MastodonAPIRequest; -import java.util.List; +import org.joinmastodon.android.api.ResultlessMastodonAPIRequest; -public class RemoveAccountsFromList extends MastodonAPIRequest { - public RemoveAccountsFromList(String listId, List accountIds){ - super(HttpMethod.DELETE, "/lists/"+listId+"/accounts", Object.class); - Request req = new Request(); - req.accountIds = accountIds; - setRequestBody(req); - } +import java.nio.charset.StandardCharsets; +import java.util.Collection; - public static class Request{ - public List accountIds; - } +import okhttp3.FormBody; + +public class RemoveAccountsFromList extends ResultlessMastodonAPIRequest{ + public RemoveAccountsFromList(String listID, Collection accountIDs){ + super(HttpMethod.DELETE, "/lists/"+listID+"/accounts"); + FormBody.Builder builder=new FormBody.Builder(StandardCharsets.UTF_8); + for(String id:accountIDs){ + builder.add("account_ids[]", id); + } + setRequestBody(builder.build()); + } } diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/requests/lists/UpdateList.java b/mastodon/src/main/java/org/joinmastodon/android/api/requests/lists/UpdateList.java index 64073fd3d..905ad0d26 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/api/requests/lists/UpdateList.java +++ b/mastodon/src/main/java/org/joinmastodon/android/api/requests/lists/UpdateList.java @@ -1,15 +1,23 @@ package org.joinmastodon.android.api.requests.lists; import org.joinmastodon.android.api.MastodonAPIRequest; -import org.joinmastodon.android.model.ListTimeline; +import org.joinmastodon.android.model.FollowList; -public class UpdateList extends MastodonAPIRequest { - public UpdateList(String id, String title, boolean exclusive, ListTimeline.RepliesPolicy repliesPolicy) { - super(HttpMethod.PUT, "/lists/" + id, ListTimeline.class); - CreateList.Request req = new CreateList.Request(); - req.title = title; - req.exclusive = exclusive; - req.repliesPolicy = repliesPolicy; - setRequestBody(req); +public class UpdateList extends MastodonAPIRequest{ + public UpdateList(String listID, String title, FollowList.RepliesPolicy repliesPolicy, boolean exclusive){ + super(HttpMethod.PUT, "/lists/"+listID, FollowList.class); + setRequestBody(new Request(title, repliesPolicy, exclusive)); + } + + private static class Request{ + public String title; + public FollowList.RepliesPolicy repliesPolicy; + public boolean exclusive; + + public Request(String title, FollowList.RepliesPolicy repliesPolicy, boolean exclusive){ + this.title=title; + this.repliesPolicy=repliesPolicy; + this.exclusive=exclusive; + } } } diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/requests/statuses/GetStatusEditHistory.java b/mastodon/src/main/java/org/joinmastodon/android/api/requests/statuses/GetStatusEditHistory.java index 608c8211c..27432bfdc 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/api/requests/statuses/GetStatusEditHistory.java +++ b/mastodon/src/main/java/org/joinmastodon/android/api/requests/statuses/GetStatusEditHistory.java @@ -26,8 +26,11 @@ public class GetStatusEditHistory extends MastodonAPIRequest>{ s.visibility=StatusPrivacy.PUBLIC; s.mentions=Collections.emptyList(); s.tags=Collections.emptyList(); - if (s.poll != null) + if(s.poll!=null){ s.poll.id="fakeID"+i; + s.poll.emojis=Collections.emptyList(); + s.poll.ownVotes=Collections.emptyList(); + } i++; } super.validateAndPostprocessResponse(respObj, httpResponse); diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/requests/tags/GetFollowedTags.java b/mastodon/src/main/java/org/joinmastodon/android/api/requests/tags/GetFollowedTags.java new file mode 100644 index 000000000..5c88a471a --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/api/requests/tags/GetFollowedTags.java @@ -0,0 +1,16 @@ +package org.joinmastodon.android.api.requests.tags; + +import com.google.gson.reflect.TypeToken; + +import org.joinmastodon.android.api.requests.HeaderPaginationRequest; +import org.joinmastodon.android.model.Hashtag; + +public class GetFollowedTags extends HeaderPaginationRequest{ + public GetFollowedTags(String maxID, int limit){ + super(HttpMethod.GET, "/followed_tags", new TypeToken<>(){}); + if(maxID!=null) + addQueryParameter("max_id", maxID); + if(limit>0) + addQueryParameter("limit", limit+""); + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/requests/timelines/GetPublicTimeline.java b/mastodon/src/main/java/org/joinmastodon/android/api/requests/timelines/GetPublicTimeline.java index 328bf869a..08c03b1c4 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/api/requests/timelines/GetPublicTimeline.java +++ b/mastodon/src/main/java/org/joinmastodon/android/api/requests/timelines/GetPublicTimeline.java @@ -10,7 +10,7 @@ import org.joinmastodon.android.model.Status; import java.util.List; public class GetPublicTimeline extends MastodonAPIRequest>{ - public GetPublicTimeline(boolean local, boolean remote, String maxID, int limit, String replyVisibility){ + public GetPublicTimeline(boolean local, boolean remote, String maxID, String minID, int limit, String sinceID, String replyVisibility){ super(HttpMethod.GET, "/timelines/public", new TypeToken<>(){}); if(local) addQueryParameter("local", "true"); @@ -18,6 +18,10 @@ public class GetPublicTimeline extends MastodonAPIRequest>{ addQueryParameter("remote", "true"); if(!TextUtils.isEmpty(maxID)) addQueryParameter("max_id", maxID); + if(!TextUtils.isEmpty(minID)) + addQueryParameter("min_id", minID); + if(!TextUtils.isEmpty(sinceID)) + addQueryParameter("since_id", sinceID); if(limit>0) addQueryParameter("limit", limit+""); if(replyVisibility != null) diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/session/AccountLocalPreferences.java b/mastodon/src/main/java/org/joinmastodon/android/api/session/AccountLocalPreferences.java index 24439acbe..e17136d9e 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/api/session/AccountLocalPreferences.java +++ b/mastodon/src/main/java/org/joinmastodon/android/api/session/AccountLocalPreferences.java @@ -50,6 +50,7 @@ public class AccountLocalPreferences{ public ShowEmojiReactions showEmojiReactions; public ColorPreference color; public ArrayList recentCustomEmoji; + public boolean preReplySheet; private final static Type recentLanguagesType=new TypeToken>() {}.getType(); private final static Type timelinesType=new TypeToken>() {}.getType(); @@ -68,6 +69,7 @@ public class AccountLocalPreferences{ revealCWs=prefs.getBoolean("revealCWs", false); hideSensitiveMedia=prefs.getBoolean("hideSensitive", true); serverSideFiltersSupported=prefs.getBoolean("serverSideFilters", false); +// preReplySheet=prefs.getBoolean("preReplySheet", false); // MEGALODON showReplies=prefs.getBoolean("showReplies", true); @@ -112,6 +114,9 @@ public class AccountLocalPreferences{ .putBoolean("hideSensitive", hideSensitiveMedia) .putBoolean("serverSideFilters", serverSideFiltersSupported) + //TODO figure this stuff out +// .putBoolean("preReplySheet", preReplySheet) + // MEGALODON .putBoolean("showReplies", showReplies) .putBoolean("showBoosts", showBoosts) diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/session/AccountSession.java b/mastodon/src/main/java/org/joinmastodon/android/api/session/AccountSession.java index 5d70e0b23..8f5a6fcba 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/api/session/AccountSession.java +++ b/mastodon/src/main/java/org/joinmastodon/android/api/session/AccountSession.java @@ -26,6 +26,7 @@ import org.joinmastodon.android.model.FilterAction; import org.joinmastodon.android.model.FilterContext; import org.joinmastodon.android.model.FilterResult; import org.joinmastodon.android.model.Instance; +import org.joinmastodon.android.model.FollowList; import org.joinmastodon.android.model.LegacyFilter; import org.joinmastodon.android.model.Preferences; import org.joinmastodon.android.model.PushSubscription; @@ -34,6 +35,7 @@ import org.joinmastodon.android.model.TimelineMarkers; import org.joinmastodon.android.model.Token; import org.joinmastodon.android.utils.ObjectIdComparator; +import java.io.File; import java.util.ArrayList; import java.util.List; import java.util.Objects; @@ -70,6 +72,7 @@ public class AccountSession{ private transient SharedPreferences prefs; private transient boolean preferencesNeedSaving; private transient AccountLocalPreferences localPreferences; + private transient List lists; AccountSession(Token token, Account self, Application app, String domain, boolean activated, AccountActivationInfo activationInfo){ this.token=token; @@ -343,4 +346,12 @@ public class AccountSession{ .map(instance->"https://"+domain+(instance.isAkkoma() ? "/images/avi.png" : "/avatars/original/missing.png")) .orElse(""); } + + public boolean isNotificationsMentionsOnly(){ + return getRawLocalPreferences().getBoolean("notificationsMentionsOnly", false); + } + + public void setNotificationsMentionsOnly(boolean mentionsOnly){ + getRawLocalPreferences().edit().putBoolean("notificationsMentionsOnly", mentionsOnly).apply(); + } } diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/session/AccountSessionManager.java b/mastodon/src/main/java/org/joinmastodon/android/api/session/AccountSessionManager.java index 33bd83ff3..6a80d5572 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/api/session/AccountSessionManager.java +++ b/mastodon/src/main/java/org/joinmastodon/android/api/session/AccountSessionManager.java @@ -216,12 +216,17 @@ public class AccountSessionManager{ public void removeAccount(String id){ AccountSession session=getAccount(id); session.getCacheController().closeDatabase(); + session.getCacheController().getListsFile().delete(); MastodonApp.context.deleteDatabase(id+".db"); MastodonApp.context.getSharedPreferences(id, 0).edit().clear().commit(); if(Build.VERSION.SDK_INT>=Build.VERSION_CODES.N){ MastodonApp.context.deleteSharedPreferences(id); }else{ - new File(MastodonApp.context.getDir("shared_prefs", Context.MODE_PRIVATE), id+".xml").delete(); + String dataDir=MastodonApp.context.getApplicationInfo().dataDir; + if(dataDir!=null){ + File prefsDir=new File(dataDir, "shared_prefs"); + new File(prefsDir, id+".xml").delete(); + } } sessions.remove(id); if(lastActiveAccountID.equals(id)){ diff --git a/mastodon/src/main/java/org/joinmastodon/android/events/AccountAddedToListEvent.java b/mastodon/src/main/java/org/joinmastodon/android/events/AccountAddedToListEvent.java new file mode 100644 index 000000000..032485381 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/events/AccountAddedToListEvent.java @@ -0,0 +1,15 @@ +package org.joinmastodon.android.events; + +import org.joinmastodon.android.model.Account; + +public class AccountAddedToListEvent{ + public final String accountID; + public final String listID; + public final Account account; + + public AccountAddedToListEvent(String accountID, String listID, Account account){ + this.accountID=accountID; + this.listID=listID; + this.account=account; + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/events/AccountRemovedFromListEvent.java b/mastodon/src/main/java/org/joinmastodon/android/events/AccountRemovedFromListEvent.java new file mode 100644 index 000000000..f7cce08e7 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/events/AccountRemovedFromListEvent.java @@ -0,0 +1,13 @@ +package org.joinmastodon.android.events; + +public class AccountRemovedFromListEvent{ + public final String accountID; + public final String listID; + public final String targetAccountID; + + public AccountRemovedFromListEvent(String accountID, String listID, String targetAccountID){ + this.accountID=accountID; + this.listID=listID; + this.targetAccountID=targetAccountID; + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/events/FinishListCreationFragmentEvent.java b/mastodon/src/main/java/org/joinmastodon/android/events/FinishListCreationFragmentEvent.java new file mode 100644 index 000000000..ec7a33346 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/events/FinishListCreationFragmentEvent.java @@ -0,0 +1,11 @@ +package org.joinmastodon.android.events; + +public class FinishListCreationFragmentEvent{ + public final String accountID; + public final String listID; + + public FinishListCreationFragmentEvent(String accountID, String listID){ + this.accountID=accountID; + this.listID=listID; + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/events/ListCreatedEvent.java b/mastodon/src/main/java/org/joinmastodon/android/events/ListCreatedEvent.java new file mode 100644 index 000000000..00de72dcc --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/events/ListCreatedEvent.java @@ -0,0 +1,13 @@ +package org.joinmastodon.android.events; + +import org.joinmastodon.android.model.FollowList; + +public class ListCreatedEvent{ + public final String accountID; + public final FollowList list; + + public ListCreatedEvent(String accountID, FollowList list){ + this.accountID=accountID; + this.list=list; + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/events/ListDeletedEvent.java b/mastodon/src/main/java/org/joinmastodon/android/events/ListDeletedEvent.java index 9824bb233..b12eaa222 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/events/ListDeletedEvent.java +++ b/mastodon/src/main/java/org/joinmastodon/android/events/ListDeletedEvent.java @@ -1,9 +1,11 @@ package org.joinmastodon.android.events; -public class ListDeletedEvent { - public final String id; +public class ListDeletedEvent{ + public final String accountID; + public final String listID; - public ListDeletedEvent(String id) { - this.id = id; + public ListDeletedEvent(String accountID, String listID){ + this.accountID=accountID; + this.listID=listID; } } diff --git a/mastodon/src/main/java/org/joinmastodon/android/events/ListUpdatedCreatedEvent.java b/mastodon/src/main/java/org/joinmastodon/android/events/ListUpdatedCreatedEvent.java index 26e0081e6..919a2950a 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/events/ListUpdatedCreatedEvent.java +++ b/mastodon/src/main/java/org/joinmastodon/android/events/ListUpdatedCreatedEvent.java @@ -1,14 +1,14 @@ package org.joinmastodon.android.events; -import org.joinmastodon.android.model.ListTimeline; +import org.joinmastodon.android.model.FollowList; public class ListUpdatedCreatedEvent { public final String id; public final String title; - public final ListTimeline.RepliesPolicy repliesPolicy; + public final FollowList.RepliesPolicy repliesPolicy; public final boolean exclusive; - public ListUpdatedCreatedEvent(String id, String title, boolean exclusive, ListTimeline.RepliesPolicy repliesPolicy) { + public ListUpdatedCreatedEvent(String id, String title, boolean exclusive, FollowList.RepliesPolicy repliesPolicy) { this.id = id; this.title = title; this.exclusive = exclusive; diff --git a/mastodon/src/main/java/org/joinmastodon/android/events/ListUpdatedEvent.java b/mastodon/src/main/java/org/joinmastodon/android/events/ListUpdatedEvent.java new file mode 100644 index 000000000..b27fe0142 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/events/ListUpdatedEvent.java @@ -0,0 +1,13 @@ +package org.joinmastodon.android.events; + +import org.joinmastodon.android.model.FollowList; + +public class ListUpdatedEvent{ + public final String accountID; + public final FollowList list; + + public ListUpdatedEvent(String accountID, FollowList list){ + this.accountID=accountID; + this.list=list; + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/AddAccountToListsFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/AddAccountToListsFragment.java new file mode 100644 index 000000000..deb59fce3 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/AddAccountToListsFragment.java @@ -0,0 +1,114 @@ +package org.joinmastodon.android.fragments; + +import android.os.Bundle; +import android.widget.TextView; + +import org.joinmastodon.android.E; +import org.joinmastodon.android.R; +import org.joinmastodon.android.api.ResultlessMastodonAPIRequest; +import org.joinmastodon.android.api.requests.accounts.GetAccountLists; +import org.joinmastodon.android.api.requests.lists.AddAccountsToList; +import org.joinmastodon.android.api.requests.lists.RemoveAccountsFromList; +import org.joinmastodon.android.api.session.AccountSessionManager; +import org.joinmastodon.android.events.AccountAddedToListEvent; +import org.joinmastodon.android.events.AccountRemovedFromListEvent; +import org.joinmastodon.android.fragments.settings.BaseSettingsFragment; +import org.joinmastodon.android.model.Account; +import org.joinmastodon.android.model.FollowList; +import org.joinmastodon.android.model.viewmodel.CheckableListItem; +import org.joinmastodon.android.ui.utils.UiUtils; +import org.parceler.Parcels; + +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; + +import androidx.recyclerview.widget.RecyclerView; +import me.grishka.appkit.api.Callback; +import me.grishka.appkit.api.ErrorResponse; +import me.grishka.appkit.api.SimpleCallback; +import me.grishka.appkit.utils.MergeRecyclerAdapter; +import me.grishka.appkit.utils.SingleViewRecyclerAdapter; +import me.grishka.appkit.utils.V; + +public class AddAccountToListsFragment extends BaseSettingsFragment{ + private Account account; + + @Override + public void onCreate(Bundle savedInstanceState){ + super.onCreate(savedInstanceState); + setTitle(R.string.add_user_to_list_title); + account=Parcels.unwrap(getArguments().getParcelable("targetAccount")); + loadData(); + } + + @Override + protected void doLoadData(int offset, int count){ + AccountSessionManager.get(accountID).getCacheController().getLists(new SimpleCallback<>(this){ + @Override + public void onSuccess(List allLists){ + if(getActivity()==null) + return; + loadAccountLists(allLists); + } + }); + } + + private void loadAccountLists(final List allLists){ + currentRequest=new GetAccountLists(account.id) + .setCallback(new SimpleCallback<>(this){ + @Override + public void onSuccess(List result){ + Set lists=result.stream().map(l->l.id).collect(Collectors.toSet()); + onDataLoaded(allLists.stream() + .map(l->new CheckableListItem<>(l.title, null, CheckableListItem.Style.CHECKBOX, lists.contains(l.id), + R.drawable.ic_list_alt_24px, AddAccountToListsFragment.this::onItemClick, l)) + .collect(Collectors.toList()), false); + } + }) + .exec(accountID); + } + + @Override + protected int indexOfItemsAdapter(){ + return 1; + } + + @Override + protected RecyclerView.Adapter getAdapter(){ + TextView topText=new TextView(getActivity()); + topText.setTextAppearance(R.style.m3_body_medium); + topText.setTextColor(UiUtils.getThemeColor(getActivity(), R.attr.colorM3OnSurface)); + topText.setPadding(V.dp(16), V.dp(8), V.dp(16), V.dp(8)); + topText.setText(getString(R.string.manage_user_lists, account.getDisplayUsername())); + + MergeRecyclerAdapter mergeAdapter=new MergeRecyclerAdapter(); + mergeAdapter.addAdapter(new SingleViewRecyclerAdapter(topText)); + mergeAdapter.addAdapter(super.getAdapter()); + return mergeAdapter; + } + + private void onItemClick(CheckableListItem item){ + boolean add=!item.checked; + ResultlessMastodonAPIRequest req=add ? new AddAccountsToList(item.parentObject.id, Set.of(account.id)) : new RemoveAccountsFromList(item.parentObject.id, Set.of(account.id)); + req.setCallback(new Callback<>(){ + @Override + public void onSuccess(Void result){ + item.checked=add; + rebindItem(item); + if(add){ + E.post(new AccountAddedToListEvent(accountID, item.parentObject.id, account)); + }else{ + E.post(new AccountRemovedFromListEvent(accountID, item.parentObject.id, account.id)); + } + } + + @Override + public void onError(ErrorResponse error){ + error.showToast(getActivity()); + } + }) + .wrapProgress(getActivity(), R.string.loading, false) + .exec(accountID); + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/BaseEditListFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/BaseEditListFragment.java new file mode 100644 index 000000000..5aa203f68 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/BaseEditListFragment.java @@ -0,0 +1,176 @@ +package org.joinmastodon.android.fragments; + +import android.app.Activity; +import android.os.Bundle; +import android.view.ViewGroup; +import android.widget.ArrayAdapter; +import android.widget.EditText; +import android.widget.LinearLayout; +import android.widget.Spinner; + +import org.joinmastodon.android.E; +import org.joinmastodon.android.R; +import org.joinmastodon.android.api.requests.lists.DeleteList; +import org.joinmastodon.android.api.requests.lists.GetListAccounts; +import org.joinmastodon.android.api.session.AccountSessionManager; +import org.joinmastodon.android.events.ListDeletedEvent; +import org.joinmastodon.android.fragments.settings.BaseSettingsFragment; +import org.joinmastodon.android.model.Account; +import org.joinmastodon.android.model.FollowList; +import org.joinmastodon.android.model.HeaderPaginationList; +import org.joinmastodon.android.model.viewmodel.AvatarPileListItem; +import org.joinmastodon.android.model.viewmodel.CheckableListItem; +import org.joinmastodon.android.model.viewmodel.ListItem; +import org.joinmastodon.android.ui.views.FloatingHintEditTextLayout; +import org.parceler.Parcels; + +import java.util.ArrayList; +import java.util.List; + +import androidx.recyclerview.widget.RecyclerView; +import me.grishka.appkit.Nav; +import me.grishka.appkit.api.APIRequest; +import me.grishka.appkit.api.Callback; +import me.grishka.appkit.api.ErrorResponse; +import me.grishka.appkit.imageloader.requests.UrlImageLoaderRequest; +import me.grishka.appkit.utils.MergeRecyclerAdapter; +import me.grishka.appkit.utils.SingleViewRecyclerAdapter; +import me.grishka.appkit.utils.V; + +public abstract class BaseEditListFragment extends BaseSettingsFragment{ + protected FollowList followList; + protected AvatarPileListItem membersItem; + protected CheckableListItem exclusiveItem; + protected FloatingHintEditTextLayout titleEditLayout; + protected EditText titleEdit; + protected Spinner showRepliesSpinner; + private APIRequest getMembersRequest; + + @Override + public void onCreate(Bundle savedInstanceState){ + super.onCreate(savedInstanceState); + followList=Parcels.unwrap(getArguments().getParcelable("list")); + + membersItem=new AvatarPileListItem<>(getString(R.string.list_members), null, List.of(), 0, i->onMembersClick(), null, false); + List> items=new ArrayList<>(); + if(followList!=null){ + items.add(membersItem); + } + exclusiveItem=new CheckableListItem<>(R.string.list_exclusive, R.string.list_exclusive_subtitle, CheckableListItem.Style.SWITCH, followList!=null && followList.exclusive, this::toggleCheckableItem); + items.add(exclusiveItem); + onDataLoaded(items); + } + + @Override + public void onDestroy(){ + super.onDestroy(); + if(getMembersRequest!=null) + getMembersRequest.cancel(); + } + + @Override + protected void doLoadData(int offset, int count){} + + @Override + protected RecyclerView.Adapter getAdapter(){ + LinearLayout topView=new LinearLayout(getActivity()); + topView.setOrientation(LinearLayout.VERTICAL); + + titleEditLayout=(FloatingHintEditTextLayout) getActivity().getLayoutInflater().inflate(R.layout.floating_hint_edit_text, topView, false); + titleEdit=titleEditLayout.findViewById(R.id.edit); + titleEdit.setHint(R.string.list_name); + titleEditLayout.updateHint(); + if(followList!=null) + titleEdit.setText(followList.title); + topView.addView(titleEditLayout); + + FloatingHintEditTextLayout showRepliesLayout=(FloatingHintEditTextLayout) getActivity().getLayoutInflater().inflate(R.layout.floating_hint_spinner, topView, false); + showRepliesSpinner=showRepliesLayout.findViewById(R.id.spinner); + showRepliesLayout.setHint(R.string.list_show_replies_to); + topView.addView(showRepliesLayout); + ArrayAdapter spinnerAdapter=new ArrayAdapter<>(getActivity(), R.layout.item_spinner, List.of( + getString(R.string.list_replies_no_one), + getString(R.string.list_replies_members), + getString(R.string.list_replies_anyone) + )); + showRepliesSpinner.setAdapter(spinnerAdapter); + showRepliesSpinner.setSelection(switch(followList!=null ? followList.repliesPolicy : FollowList.RepliesPolicy.LIST){ + case FOLLOWED -> 2; + case LIST -> 1; + case NONE -> 0; + }); + ViewGroup.MarginLayoutParams llp=(ViewGroup.MarginLayoutParams)showRepliesLayout.getLabel().getLayoutParams(); + llp.setMarginStart(llp.getMarginStart()+V.dp(16)); + + MergeRecyclerAdapter adapter=new MergeRecyclerAdapter(); + adapter.addAdapter(new SingleViewRecyclerAdapter(topView)); + adapter.addAdapter(super.getAdapter()); + return adapter; + } + + @Override + protected int indexOfItemsAdapter(){ + return 1; + } + + protected void doDeleteList(){ + new DeleteList(followList.id) + .setCallback(new Callback<>(){ + @Override + public void onSuccess(Void result){ + AccountSessionManager.get(accountID).getCacheController().deleteList(followList.id); + E.post(new ListDeletedEvent(accountID, followList.id)); + Nav.finish(BaseEditListFragment.this); + } + + @Override + public void onError(ErrorResponse error){ + Activity activity=getActivity(); + if(activity==null) + return; + error.showToast(activity); + } + }) + .wrapProgress(getActivity(), R.string.loading, true) + .exec(accountID); + } + + private void onMembersClick(){ + Bundle args=new Bundle(); + args.putString("account", accountID); + args.putParcelable("list", Parcels.wrap(followList)); + Nav.go(getActivity(), ListMembersFragment.class, args); + } + + protected void loadMembers(){ + getMembersRequest=new GetListAccounts(followList.id, null, 3) + .setCallback(new Callback<>(){ + @Override + public void onSuccess(HeaderPaginationList result){ + getMembersRequest=null; + membersItem.avatars=new ArrayList<>(); + for(int i=0;i FollowList.RepliesPolicy.NONE; + case 1 -> FollowList.RepliesPolicy.LIST; + case 2 -> FollowList.RepliesPolicy.FOLLOWED; + default -> throw new IllegalStateException("Unexpected value: "+showRepliesSpinner.getSelectedItemPosition()); + }; + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/BaseStatusListFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/BaseStatusListFragment.java index acaabf44a..ea6c5f99e 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/BaseStatusListFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/BaseStatusListFragment.java @@ -28,6 +28,8 @@ import org.joinmastodon.android.api.requests.accounts.GetAccountRelationships; import org.joinmastodon.android.api.requests.polls.SubmitPollVote; import org.joinmastodon.android.api.requests.statuses.AkkomaTranslateStatus; import org.joinmastodon.android.api.requests.statuses.TranslateStatus; +import org.joinmastodon.android.api.session.AccountLocalPreferences; +import org.joinmastodon.android.api.session.AccountSessionManager; import org.joinmastodon.android.api.session.AccountSessionManager; import org.joinmastodon.android.api.session.AccountSessionManager; import org.joinmastodon.android.events.PollUpdatedEvent; @@ -40,6 +42,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.FooterStatusDisplayItem; @@ -63,6 +67,8 @@ import org.joinmastodon.android.ui.utils.UiUtils; import org.joinmastodon.android.utils.ProvidesAssistContent; 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; @@ -145,6 +151,7 @@ public abstract class BaseStatusListFragment exten for(T s:items){ displayItems.addAll(buildDisplayItems(s)); } + loadRelationships(items.stream().map(DisplayItemsParent::getAccountID).filter(Objects::nonNull).collect(Collectors.toSet())); } @Override @@ -166,6 +173,7 @@ public abstract class BaseStatusListFragment exten } if(notify) adapter.notifyItemRangeInserted(0, offset); + loadRelationships(items.stream().map(DisplayItemsParent::getAccountID).filter(Objects::nonNull).collect(Collectors.toSet())); return offset; } @@ -222,7 +230,7 @@ public abstract class BaseStatusListFragment exten @Override public void openPhotoViewer(String parentID, Status _status, int attachmentIndex, MediaGridStatusDisplayItem.Holder gridHolder){ final Status status=_status.getContentStatus(); - currentPhotoViewer=new PhotoViewer(getActivity(), status.mediaAttachments, attachmentIndex, new PhotoViewer.Listener(){ + currentPhotoViewer=new PhotoViewer(getActivity(), status.mediaAttachments, attachmentIndex, status, accountID, new PhotoViewer.Listener(){ private MediaAttachmentViewController transitioningHolder; @Override @@ -288,6 +296,7 @@ public abstract class BaseStatusListFragment exten @Override public void photoViewerDismissed(){ currentPhotoViewer=null; + gridHolder.itemView.setHasTransientState(false); } @Override @@ -299,12 +308,13 @@ public abstract class BaseStatusListFragment exten return gridHolder.getViewController(index); } }); + gridHolder.itemView.setHasTransientState(true); } public void openPreviewlessMediaPhotoViewer(String parentID, Status _status, int attachmentIndex, PreviewlessMediaGridStatusDisplayItem.Holder gridHolder){ final Status status=_status.getContentStatus(); - currentPhotoViewer=new PhotoViewer(getActivity(), status.mediaAttachments, attachmentIndex, new PhotoViewer.Listener(){ + currentPhotoViewer=new PhotoViewer(getActivity(), status.mediaAttachments, attachmentIndex, status, accountID, new PhotoViewer.Listener(){ private PreviewlessMediaAttachmentViewController transitioningHolder; @Override @@ -775,6 +785,9 @@ public abstract class BaseStatusListFragment exten } protected void loadRelationships(Set 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 @@ -1066,6 +1079,26 @@ public abstract class BaseStatusListFragment 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, accountID).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 holder){} @Override diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/ComposeFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/ComposeFragment.java index 70cc98df3..90dee6085 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/ComposeFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/ComposeFragment.java @@ -7,6 +7,9 @@ import static org.joinmastodon.android.api.requests.statuses.CreateStatus.getDra import android.Manifest; import android.animation.ObjectAnimator; +import android.animation.Animator; +import android.animation.AnimatorSet; +import android.animation.ObjectAnimator; import android.annotation.SuppressLint; import android.app.Activity; import android.app.DatePickerDialog; @@ -75,7 +78,7 @@ import org.joinmastodon.android.events.ScheduledStatusDeletedEvent; import org.joinmastodon.android.events.StatusCountersUpdatedEvent; import org.joinmastodon.android.events.StatusCreatedEvent; import org.joinmastodon.android.events.StatusUpdatedEvent; -import org.joinmastodon.android.fragments.account_list.ComposeAccountSearchFragment; +import org.joinmastodon.android.fragments.account_list.AccountSearchFragment; import org.joinmastodon.android.model.Account; import org.joinmastodon.android.model.ContentType; import org.joinmastodon.android.model.Emoji; @@ -135,12 +138,14 @@ import java.util.stream.Collectors; import me.grishka.appkit.Nav; import me.grishka.appkit.api.Callback; import me.grishka.appkit.api.ErrorResponse; +import me.grishka.appkit.fragments.CustomTransitionsFragment; import me.grishka.appkit.fragments.OnBackPressedListener; import me.grishka.appkit.imageloader.ViewImageLoader; import me.grishka.appkit.imageloader.requests.UrlImageLoaderRequest; +import me.grishka.appkit.utils.CubicBezierInterpolator; import me.grishka.appkit.utils.V; -public class ComposeFragment extends MastodonToolbarFragment implements OnBackPressedListener, ComposeEditText.SelectionListener, HasAccountID { +public class ComposeFragment extends MastodonToolbarFragment implements OnBackPressedListener, ComposeEditText.SelectionListener, HasAccountID, CustomTransitionsFragment { private static final int MEDIA_RESULT=717; public static final int IMAGE_DESCRIPTION_RESULT=363; @@ -527,7 +532,7 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr public void onLaunchAccountSearch(){ Bundle args=new Bundle(); args.putString("account", accountID); - Nav.goForResult(getActivity(), ComposeAccountSearchFragment.class, args, AUTOCOMPLETE_ACCOUNT_RESULT, ComposeFragment.this); + Nav.goForResult(getActivity(), AccountSearchFragment.class, args, AUTOCOMPLETE_ACCOUNT_RESULT, ComposeFragment.this); } }); View autocompleteView=autocompleteViewController.getView(); @@ -1806,8 +1811,26 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr return new String[]{"image/jpeg", "image/gif", "image/png", "video/mp4"}; } + private String sanitizeMediaDescription(String description){ + if(description == null){ + return null; + } + + // The Gboard android keyboard attaches this text whenever the user + // pastes something from the keyboard's suggestion bar. + // Due to different end user locales, the exact text may vary, but at + // least in version 13.4.08, all of the translations contained the + // string "Gboard". + if (description.contains("Gboard")){ + return null; + } + + return description; + } + @Override public boolean onAddMediaAttachmentFromEditText(Uri uri, String description){ + description = sanitizeMediaDescription(description); return mediaViewController.addMediaAttachment(uri, description); } @@ -1850,6 +1873,8 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr Editable e=mainEditText.getText(); int start=e.getSpanStart(currentAutocompleteSpan); int end=e.getSpanEnd(currentAutocompleteSpan); + if(start==-1 || end==-1) + return; e.replace(start, end, text+" "); finishAutocomplete(); InputConnection conn=mainEditText.getCurrentInputConnection(); @@ -1918,4 +1943,35 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr languageButton.setText(opt.language.getLanguageName()); languageButton.setContentDescription(getActivity().getString(R.string.sk_post_language, opt.language.getDefaultName())); } + + @Override + public Animator onCreateEnterTransition(View prev, View container){ + AnimatorSet anim=new AnimatorSet(); + if(getArguments().getBoolean("fromThreadFragment")){ + anim.playTogether( + ObjectAnimator.ofFloat(container, View.ALPHA, 0f, 1f), + ObjectAnimator.ofFloat(container, View.TRANSLATION_Y, V.dp(200), 0) + ); + }else{ + anim.playTogether( + ObjectAnimator.ofFloat(container, View.ALPHA, 0f, 1f), + ObjectAnimator.ofFloat(container, View.TRANSLATION_X, V.dp(100), 0) + ); + } + anim.setDuration(300); + anim.setInterpolator(CubicBezierInterpolator.DEFAULT); + return anim; + } + + @Override + public Animator onCreateExitTransition(View prev, View container){ + AnimatorSet anim=new AnimatorSet(); + anim.playTogether( + ObjectAnimator.ofFloat(container, View.TRANSLATION_X, V.dp(100)), + ObjectAnimator.ofFloat(container, View.ALPHA, 0) + ); + anim.setDuration(200); + anim.setInterpolator(CubicBezierInterpolator.DEFAULT); + return anim; + } } diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/ComposeImageDescriptionFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/ComposeImageDescriptionFragment.java index 733bc0815..a82f95592 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/ComposeImageDescriptionFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/ComposeImageDescriptionFragment.java @@ -7,10 +7,7 @@ import android.graphics.Rect; import android.graphics.drawable.Drawable; import android.media.MediaMetadataRetriever; import android.net.Uri; -import android.os.Build; import android.os.Bundle; -import android.text.SpannableStringBuilder; -import android.text.style.BulletSpan; import android.util.Log; import android.view.ContextThemeWrapper; import android.view.LayoutInflater; @@ -135,20 +132,9 @@ public class ComposeImageDescriptionFragment extends MastodonToolbarFragment imp @Override public boolean onOptionsItemSelected(MenuItem item){ if(item.getItemId()==R.id.help){ - SpannableStringBuilder msg=new SpannableStringBuilder(getText(R.string.alt_text_help)); - BulletSpan[] spans=msg.getSpans(0, msg.length(), BulletSpan.class); - for(BulletSpan span:spans){ - BulletSpan betterSpan; - if(Build.VERSION.SDK_INT accountIDsInList=new HashSet<>(); + + @Override + public void onCreate(Bundle savedInstanceState){ + super.onCreate(savedInstanceState); + setTitle(R.string.manage_list_members); + setSubtitle(getString(R.string.step_x_of_y, 2, 2)); + setLayout(R.layout.fragment_login); + setEmptyText(R.string.list_no_members); + setHasOptionsMenu(true); + + followList=Parcels.unwrap(getArguments().getParcelable("list")); + if(savedInstanceState!=null || getArguments().getBoolean("needLoadMembers", false)){ + loadData(); + }else{ + onDataLoaded(List.of()); + } + } + + @Override + protected void doLoadData(int offset, int count){ + currentRequest=new GetListAccounts(followList.id, null, 0) + .setCallback(new SimpleCallback<>(this){ + @Override + public void onSuccess(HeaderPaginationList result){ + for(Account acc:result) + accountIDsInList.add(acc.id); + onDataLoaded(result.stream().map(a->new AccountViewModel(a, accountID)).collect(Collectors.toList())); + } + }) + .exec(accountID); + } + + @Override + public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState){ + View view=super.onCreateView(inflater, container, savedInstanceState); + FrameLayout wrapper=new FrameLayout(getActivity()); + wrapper.addView(view); + rootView=(FragmentRootLinearLayout) view; + fragmentContentWrap=wrapper; + return wrapper; + } + + @Override + public void onViewCreated(View view, Bundle savedInstanceState){ + nextButton=view.findViewById(R.id.btn_next); + nextButton.setOnClickListener(this::onNextClick); + nextButton.setText(R.string.done); + buttonBar=view.findViewById(R.id.button_bar); + + super.onViewCreated(view, savedInstanceState); + } + + @Override + public void onApplyWindowInsets(WindowInsets insets){ + lastInsets=insets; + if(searchFragment!=null) + searchFragment.onApplyWindowInsets(insets); + insets=UiUtils.applyBottomInsetToFixedView(buttonBar, insets); + rootView.dispatchApplyWindowInsets(insets); + } + + @Override + protected List getViewsForElevationEffect(){ + return List.of(getToolbar(), buttonBar); + } + + @Override + public void onCreateOptionsMenu(Menu menu, MenuInflater inflater){ + MenuItem item=menu.add(R.string.add_list_member); + item.setIcon(R.drawable.ic_fluent_add_24_regular); + item.setShowAsAction(MenuItem.SHOW_AS_ACTION_ALWAYS); + } + + @Override + public boolean onOptionsItemSelected(MenuItem item){ + if(searchFragmentContainer!=null) + return true; + + searchFragmentContainer=new FrameLayout(getActivity()); + searchFragmentContainer.setId(R.id.search_fragment); + fragmentContentWrap.addView(searchFragmentContainer); + + Bundle args=new Bundle(); + args.putString("account", accountID); + args.putParcelable("list", Parcels.wrap(followList)); + args.putBoolean("_can_go_back", true); + searchFragment=new AddNewListMembersFragment(this); + searchFragment.setArguments(args); + getChildFragmentManager().beginTransaction().add(R.id.search_fragment, searchFragment).commit(); + getChildFragmentManager().executePendingTransactions(); + if(lastInsets!=null) + searchFragment.onApplyWindowInsets(lastInsets); + searchFragmentContainer.setTranslationX(V.dp(100)); + searchFragmentContainer.setAlpha(0f); + searchFragmentContainer.animate().translationX(0).alpha(1).setDuration(300).withLayer().setInterpolator(CubicBezierInterpolator.DEFAULT).withEndAction(()->{ + rootView.setVisibility(View.GONE); + }).start(); + return true; + } + + @Override + protected void initializeEmptyView(View contentView){ + ViewStub emptyStub=contentView.findViewById(R.id.empty); + emptyStub.setLayoutResource(R.layout.empty_with_arrow); + super.initializeEmptyView(contentView); + TextView emptySecondary=contentView.findViewById(R.id.empty_text_secondary); + emptySecondary.setText(R.string.list_find_users); + CurlyArrowEmptyView arrowView=(CurlyArrowEmptyView) emptyView; + arrowView.setGravityAndOffsets(Gravity.TOP | Gravity.END, 24, 2); + } + + @Override + protected void setStatusBarColor(int color){ + rootView.setStatusBarColor(color); + } + + @Override + protected void setNavigationBarColor(int color){ + rootView.setNavigationBarColor(color); + } + + private void dismissSearchFragment(){ + if(searchFragment==null || dismissingSearchFragment) + return; + dismissingSearchFragment=true; + rootView.setVisibility(View.VISIBLE); + searchFragmentContainer.animate().translationX(V.dp(100)).alpha(0).setDuration(200).withLayer().setInterpolator(CubicBezierInterpolator.DEFAULT).withEndAction(()->{ + getChildFragmentManager().beginTransaction().remove(searchFragment).commit(); + getChildFragmentManager().executePendingTransactions(); + fragmentContentWrap.removeView(searchFragmentContainer); + searchFragmentContainer=null; + searchFragment=null; + dismissingSearchFragment=false; + }).start(); + getActivity().getSystemService(InputMethodManager.class).hideSoftInputFromWindow(contentView.getWindowToken(), 0); + } + + private void onNextClick(View v){ + E.post(new FinishListCreationFragmentEvent(accountID, followList.id)); + Nav.finish(this); + } + + @Override + public boolean onBackPressed(){ + if(searchFragment!=null){ + dismissSearchFragment(); + return true; + } + return false; + } + + @Override + public boolean isAccountInList(AccountViewModel account){ + return accountIDsInList.contains(account.account.id); + } + + @Override + public void addAccountToList(AccountViewModel account, Runnable onDone){ + new AddAccountsToList(followList.id, Set.of(account.account.id)) + .setCallback(new Callback<>(){ + @Override + public void onSuccess(Void result){ + accountIDsInList.add(account.account.id); + if(onDone!=null) + onDone.run(); + int i=0; + for(AccountViewModel acc:data){ + if(acc.account.id.equals(account.account.id)){ + list.getAdapter().notifyItemChanged(i); + return; + } + i++; + } + int pos=data.size(); + data.add(account); + list.getAdapter().notifyItemInserted(pos); + } + + @Override + public void onError(ErrorResponse error){ + error.showToast(getActivity()); + } + }) + .exec(accountID); + } + + @Override + public void removeAccountAccountFromList(AccountViewModel account, Runnable onDone){ + new RemoveAccountsFromList(followList.id, Set.of(account.account.id)) + .setCallback(new Callback<>(){ + @Override + public void onSuccess(Void result){ + accountIDsInList.remove(account.account.id); + if(onDone!=null) + onDone.run(); + int i=0; + for(AccountViewModel acc:data){ + if(acc.account.id.equals(account.account.id)){ + list.getAdapter().notifyItemChanged(i); + return; + } + i++; + } + } + + @Override + public void onError(ErrorResponse error){ + error.showToast(getActivity()); + } + }) + .exec(accountID); + } + + @Override + protected void onConfigureViewHolder(AccountViewHolder holder){ + holder.setStyle(AccountViewHolder.AccessoryType.CUSTOM_BUTTON, false); + holder.setOnLongClickListener(vh->false); + Button button=holder.getButton(); + button.setPadding(V.dp(24), 0, V.dp(24), 0); + button.setMinimumWidth(0); + button.setMinWidth(0); + button.setOnClickListener(v->{ + holder.setActionProgressVisible(true); + holder.itemView.setHasTransientState(true); + Runnable onDone=()->{ + holder.setActionProgressVisible(false); + holder.itemView.setHasTransientState(false); + }; + AccountViewModel account=holder.getItem(); + if(isAccountInList(account)){ + removeAccountAccountFromList(account, onDone); + }else{ + addAccountToList(account, onDone); + } + }); + } + + @Override + protected void onBindViewHolder(AccountViewHolder holder){ + Button button=holder.getButton(); + int textRes, styleRes; + if(isAccountInList(holder.getItem())){ + textRes=R.string.remove; + styleRes=R.style.Widget_Mastodon_M3_Button_Tonal_Error; + }else{ + textRes=R.string.add; + styleRes=R.style.Widget_Mastodon_M3_Button_Filled; + } + button.setText(textRes); + TypedArray ta=button.getContext().obtainStyledAttributes(styleRes, new int[]{android.R.attr.background}); + button.setBackground(ta.getDrawable(0)); + ta.recycle(); + ta=button.getContext().obtainStyledAttributes(styleRes, new int[]{android.R.attr.textColor}); + button.setTextColor(ta.getColorStateList(0)); + ta.recycle(); + } + + @Override + protected void loadRelationships(List accounts){ + // no-op + } + + @Override + public Uri getWebUri(Uri.Builder base){ + // TODO this + return null; + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/CreateListFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/CreateListFragment.java new file mode 100644 index 000000000..9ae409045 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/CreateListFragment.java @@ -0,0 +1,149 @@ +package org.joinmastodon.android.fragments; + +import android.os.Bundle; +import android.text.TextUtils; +import android.view.View; +import android.view.WindowInsets; +import android.view.inputmethod.InputMethodManager; +import android.widget.Button; + +import com.squareup.otto.Subscribe; + +import org.joinmastodon.android.E; +import org.joinmastodon.android.R; +import org.joinmastodon.android.api.requests.lists.CreateList; +import org.joinmastodon.android.api.requests.lists.UpdateList; +import org.joinmastodon.android.api.session.AccountSessionManager; +import org.joinmastodon.android.events.FinishListCreationFragmentEvent; +import org.joinmastodon.android.events.ListCreatedEvent; +import org.joinmastodon.android.events.ListUpdatedEvent; +import org.joinmastodon.android.model.FollowList; +import org.joinmastodon.android.ui.utils.UiUtils; +import org.parceler.Parcels; + +import java.util.List; + +import me.grishka.appkit.Nav; +import me.grishka.appkit.api.Callback; +import me.grishka.appkit.api.ErrorResponse; + +public class CreateListFragment extends BaseEditListFragment{ + private Button nextButton; + private View buttonBar; + private FollowList followList; + + @Override + public void onCreate(Bundle savedInstanceState){ + super.onCreate(savedInstanceState); + setTitle(R.string.create_list); + setSubtitle(getString(R.string.step_x_of_y, 1, 2)); + setLayout(R.layout.fragment_login); + if(savedInstanceState!=null) + followList=Parcels.unwrap(savedInstanceState.getParcelable("list")); + E.register(this); + } + + @Override + public void onDestroy(){ + super.onDestroy(); + E.unregister(this); + } + + @Override + protected int getNavigationIconDrawableResource(){ + return R.drawable.ic_baseline_arrow_drop_down_18; + } + + @Override + public boolean wantsCustomNavigationIcon(){ + return true; + } + + @Override + public void onViewCreated(View view, Bundle savedInstanceState){ + nextButton=view.findViewById(R.id.btn_next); + nextButton.setOnClickListener(this::onNextClick); + nextButton.setText(R.string.create); + buttonBar=view.findViewById(R.id.button_bar); + super.onViewCreated(view, savedInstanceState); + } + + @Override + public void onApplyWindowInsets(WindowInsets insets){ + super.onApplyWindowInsets(UiUtils.applyBottomInsetToFixedView(buttonBar, insets)); + } + + @Override + protected List getViewsForElevationEffect(){ + return List.of(getToolbar(), buttonBar); + } + + @Override + public void onSaveInstanceState(Bundle outState){ + super.onSaveInstanceState(outState); + outState.putParcelable("list", Parcels.wrap(followList)); + } + + private void onNextClick(View v){ + String title=titleEdit.getText().toString().trim(); + if(TextUtils.isEmpty(title)){ + titleEditLayout.setErrorState(getString(R.string.required_form_field_blank)); + return; + } + if(followList==null){ + new CreateList(title, getSelectedRepliesPolicy(), exclusiveItem.checked) + .setCallback(new Callback<>(){ + @Override + public void onSuccess(FollowList result){ + followList=result; + proceed(false); + E.post(new ListCreatedEvent(accountID, result)); + AccountSessionManager.get(accountID).getCacheController().addList(result); + } + + @Override + public void onError(ErrorResponse error){ + error.showToast(getActivity()); + } + }) + .wrapProgress(getActivity(), R.string.loading, true) + .exec(accountID); + }else if(!title.equals(followList.title) || getSelectedRepliesPolicy()!=followList.repliesPolicy || exclusiveItem.checked!=followList.exclusive){ + new UpdateList(followList.id, title, getSelectedRepliesPolicy(), exclusiveItem.checked) + .setCallback(new Callback<>(){ + @Override + public void onSuccess(FollowList result){ + followList=result; + proceed(true); + E.post(new ListUpdatedEvent(accountID, result)); + AccountSessionManager.get(accountID).getCacheController().updateList(result); + } + + @Override + public void onError(ErrorResponse error){ + error.showToast(getActivity()); + } + }) + .wrapProgress(getActivity(), R.string.loading, true) + .exec(accountID); + }else{ + proceed(true); + } + } + + private void proceed(boolean needLoadMembers){ + Bundle args=new Bundle(); + args.putString("account", accountID); + args.putParcelable("list", Parcels.wrap(followList)); + args.putBoolean("needLoadMembers", needLoadMembers); + Nav.go(getActivity(), CreateListAddMembersFragment.class, args); + getActivity().getSystemService(InputMethodManager.class).hideSoftInputFromWindow(contentView.getWindowToken(), 0); + } + + @Subscribe + public void onFinishListCreationFragment(FinishListCreationFragmentEvent ev){ + if(ev.accountID.equals(accountID) && followList!=null && ev.listID.equals(followList.id)){ + Nav.finish(this); + } + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/CustomLocalTimelineFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/CustomLocalTimelineFragment.java index 2f499280e..9fd6bcef4 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/CustomLocalTimelineFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/CustomLocalTimelineFragment.java @@ -46,7 +46,7 @@ public class CustomLocalTimelineFragment extends PinnableStatusListFragment impl @Override protected void doLoadData(int offset, int count){ - currentRequest=new GetPublicTimeline(true, false, refreshing ? null : maxID, count, getLocalPrefs().timelineReplyVisibility) + currentRequest=new GetPublicTimeline(true, false, refreshing ? null : maxID, null, count, null, getLocalPrefs().timelineReplyVisibility) .setCallback(new SimpleCallback<>(this){ @Override public void onSuccess(List result){ diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/EditListFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/EditListFragment.java new file mode 100644 index 000000000..0d8244159 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/EditListFragment.java @@ -0,0 +1,67 @@ +package org.joinmastodon.android.fragments; + +import android.os.Bundle; +import android.view.Menu; +import android.view.MenuInflater; +import android.view.MenuItem; + +import org.joinmastodon.android.E; +import org.joinmastodon.android.R; +import org.joinmastodon.android.api.requests.lists.UpdateList; +import org.joinmastodon.android.api.session.AccountSessionManager; +import org.joinmastodon.android.events.ListUpdatedEvent; +import org.joinmastodon.android.model.FollowList; +import org.joinmastodon.android.ui.M3AlertDialogBuilder; + +import me.grishka.appkit.api.Callback; +import me.grishka.appkit.api.ErrorResponse; + +public class EditListFragment extends BaseEditListFragment{ + @Override + public void onCreate(Bundle savedInstanceState){ + super.onCreate(savedInstanceState); + setTitle(R.string.edit_list); + loadMembers(); + setHasOptionsMenu(true); + } + + @Override + public void onCreateOptionsMenu(Menu menu, MenuInflater inflater){ + menu.add(R.string.delete_list); + } + + @Override + public boolean onOptionsItemSelected(MenuItem item){ + new M3AlertDialogBuilder(getActivity()) + .setTitle(R.string.delete_list) + .setMessage(getString(R.string.delete_list_confirm, followList.title)) + .setPositiveButton(R.string.delete, (dlg, which)->doDeleteList()) + .setNegativeButton(R.string.cancel, null) + .show(); + return true; + } + + @Override + public void onDestroy(){ + super.onDestroy(); + String newTitle=titleEdit.getText().toString(); + FollowList.RepliesPolicy newRepliesPolicy=getSelectedRepliesPolicy(); + boolean newExclusive=exclusiveItem.checked; + if(!newTitle.equals(followList.title) || newRepliesPolicy!=followList.repliesPolicy || newExclusive!=followList.exclusive){ + new UpdateList(followList.id, newTitle, newRepliesPolicy, newExclusive) + .setCallback(new Callback<>(){ + @Override + public void onSuccess(FollowList result){ + AccountSessionManager.get(accountID).getCacheController().updateList(result); + E.post(new ListUpdatedEvent(accountID, result)); + } + + @Override + public void onError(ErrorResponse error){ + // TODO handle errors somehow + } + }) + .exec(accountID); + } + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/EditTimelinesFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/EditTimelinesFragment.java index 9d03a10df..195de9796 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/EditTimelinesFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/EditTimelinesFragment.java @@ -41,17 +41,14 @@ import org.joinmastodon.android.api.requests.lists.GetLists; import org.joinmastodon.android.api.requests.tags.GetFollowedHashtags; import org.joinmastodon.android.api.session.AccountLocalPreferences; import org.joinmastodon.android.api.session.AccountSessionManager; -import org.joinmastodon.android.api.session.AccountSession; -import org.joinmastodon.android.api.session.AccountSessionManager; import org.joinmastodon.android.model.CustomLocalTimeline; +import org.joinmastodon.android.model.FollowList; import org.joinmastodon.android.model.Hashtag; import org.joinmastodon.android.model.HeaderPaginationList; -import org.joinmastodon.android.model.ListTimeline; import org.joinmastodon.android.model.TimelineDefinition; import org.joinmastodon.android.ui.DividerItemDecoration; import org.joinmastodon.android.ui.M3AlertDialogBuilder; import org.joinmastodon.android.ui.utils.UiUtils; -import org.joinmastodon.android.ui.views.TextInputFrameLayout; import java.util.ArrayList; import java.util.Collections; @@ -74,7 +71,7 @@ public class EditTimelinesFragment extends MastodonRecyclerFragment timelineByMenuItem=new HashMap<>(); - private final List listTimelines=new ArrayList<>(); + private final List followLists =new ArrayList<>(); private final List hashtags=new ArrayList<>(); private MenuItem addHashtagItem; private final List localTimelines = new ArrayList<>(); @@ -94,8 +91,8 @@ public class EditTimelinesFragment extends MastodonRecyclerFragment(){ @Override - public void onSuccess(List result){ - listTimelines.addAll(result); + public void onSuccess(List result){ + followLists.addAll(result); updateOptionsMenu(); } @@ -224,7 +221,7 @@ public class EditTimelinesFragment extends MastodonRecyclerFragmentaddTimelineToOptions(tl, timelinesMenu)); - listTimelines.stream().map(TimelineDefinition::ofList).forEach(tl->addTimelineToOptions(tl, listsMenu)); + followLists.stream().map(TimelineDefinition::ofList).forEach(tl->addTimelineToOptions(tl, listsMenu)); addHashtagItem=addOptionsItem(hashtagsMenu, getContext().getString(R.string.sk_timelines_add), R.drawable.ic_fluent_add_24_regular); hashtags.stream().map(TimelineDefinition::ofHashtag).forEach(tl->addTimelineToOptions(tl, hashtagsMenu)); diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/HashtagTimelineFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/HashtagTimelineFragment.java index 705d2738e..b049673ff 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/HashtagTimelineFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/HashtagTimelineFragment.java @@ -32,6 +32,8 @@ import org.joinmastodon.android.model.FilterAction; import org.joinmastodon.android.api.session.AccountSessionManager; import org.joinmastodon.android.model.FilterContext; import org.joinmastodon.android.model.FilterKeyword; +import org.joinmastodon.android.api.session.AccountSessionManager; +import org.joinmastodon.android.model.FilterContext; import org.joinmastodon.android.model.Hashtag; import org.joinmastodon.android.model.Status; import org.joinmastodon.android.model.TimelineDefinition; @@ -63,6 +65,7 @@ public class HashtagTimelineFragment extends PinnableStatusListFragment{ private MenuItem followMenuItem, pinMenuItem, muteMenuItem; private boolean followRequestRunning; private boolean toolbarContentVisible; + private String maxID; private List any; private List all; diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/HomeFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/HomeFragment.java index 78b8a3394..1217ee88a 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/HomeFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/HomeFragment.java @@ -5,8 +5,6 @@ import android.app.Fragment; import android.app.NotificationManager; import android.app.assist.AssistContent; import android.graphics.drawable.RippleDrawable; -import android.content.Intent; -import android.graphics.Outline; import android.os.Build; import android.os.Bundle; import android.service.notification.StatusBarNotification; @@ -37,7 +35,7 @@ import org.joinmastodon.android.fragments.onboarding.OnboardingFollowSuggestions import org.joinmastodon.android.model.Account; import org.joinmastodon.android.model.Notification; import org.joinmastodon.android.model.PaginatedResponse; -import org.joinmastodon.android.ui.AccountSwitcherSheet; +import org.joinmastodon.android.ui.sheets.AccountSwitcherSheet; import org.joinmastodon.android.ui.OutlineProviders; import org.joinmastodon.android.ui.utils.UiUtils; import org.joinmastodon.android.ui.views.TabBar; diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/HomeTabFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/HomeTabFragment.java index 42aacbd6b..802aae4bc 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/HomeTabFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/HomeTabFragment.java @@ -53,7 +53,7 @@ import org.joinmastodon.android.fragments.settings.SettingsMainFragment; import org.joinmastodon.android.model.Announcement; import org.joinmastodon.android.model.Hashtag; import org.joinmastodon.android.model.HeaderPaginationList; -import org.joinmastodon.android.model.ListTimeline; +import org.joinmastodon.android.model.FollowList; import org.joinmastodon.android.model.TimelineDefinition; import org.joinmastodon.android.ui.SimpleViewHolder; import org.joinmastodon.android.ui.utils.UiUtils; @@ -95,7 +95,7 @@ public class HomeTabFragment extends MastodonToolbarFragment implements Scrollab private ImageView collapsedChevron; private TextView timelineTitle; private PopupMenu switcherPopup; - private final Map listItems = new HashMap<>(); + private final Map listItems = new HashMap<>(); private final Map hashtagsItems = new HashMap<>(); private List timelinesList; private int count; @@ -270,7 +270,7 @@ public class HomeTabFragment extends MastodonToolbarFragment implements Scrollab new GetLists().setCallback(new Callback<>() { @Override - public void onSuccess(List lists) { + public void onSuccess(List lists) { updateList(lists, listItems); } @@ -512,7 +512,7 @@ public class HomeTabFragment extends MastodonToolbarFragment implements Scrollab Bundle args=new Bundle(); args.putString("account", accountID); int id = item.getItemId(); - ListTimeline list; + FollowList list; Hashtag hashtag; if (item.getItemId() == R.id.menu_back) { @@ -701,13 +701,13 @@ public class HomeTabFragment extends MastodonToolbarFragment implements Scrollab @Subscribe public void onListDeletedEvent(ListDeletedEvent event) { - handleListEvent(listItems, l -> l.id.equals(event.id), false, null); + handleListEvent(listItems, l -> l.id.equals(event.listID), false, null); } @Subscribe public void onListUpdatedCreatedEvent(ListUpdatedCreatedEvent event) { handleListEvent(listItems, l -> l.id.equals(event.id), true, () -> { - ListTimeline list = new ListTimeline(); + FollowList list = new FollowList(); list.id = event.id; list.title = event.title; list.repliesPolicy = event.repliesPolicy; diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/ListMembersFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/ListMembersFragment.java new file mode 100644 index 000000000..83259e155 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/ListMembersFragment.java @@ -0,0 +1,324 @@ +package org.joinmastodon.android.fragments; + +import android.net.Uri; +import android.os.Bundle; +import android.view.ActionMode; +import android.view.Menu; +import android.view.MenuInflater; +import android.view.MenuItem; +import android.view.View; +import android.view.WindowInsets; +import android.widget.ImageButton; + +import com.squareup.otto.Subscribe; + +import org.joinmastodon.android.E; +import org.joinmastodon.android.R; +import org.joinmastodon.android.api.MastodonAPIRequest; +import org.joinmastodon.android.api.requests.HeaderPaginationRequest; +import org.joinmastodon.android.api.requests.lists.AddAccountsToList; +import org.joinmastodon.android.api.requests.lists.GetListAccounts; +import org.joinmastodon.android.api.requests.lists.RemoveAccountsFromList; +import org.joinmastodon.android.events.AccountAddedToListEvent; +import org.joinmastodon.android.events.AccountRemovedFromListEvent; +import org.joinmastodon.android.fragments.account_list.AddListMembersFragment; +import org.joinmastodon.android.fragments.account_list.PaginatedAccountListFragment; +import org.joinmastodon.android.model.Account; +import org.joinmastodon.android.model.FollowList; +import org.joinmastodon.android.model.viewmodel.AccountViewModel; +import org.joinmastodon.android.ui.M3AlertDialogBuilder; +import org.joinmastodon.android.ui.utils.ActionModeHelper; +import org.joinmastodon.android.ui.utils.UiUtils; +import org.joinmastodon.android.ui.viewholders.AccountViewHolder; +import org.parceler.Parcels; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.HashSet; +import java.util.List; +import java.util.Objects; +import java.util.Set; +import java.util.stream.Collectors; + +import me.grishka.appkit.Nav; +import me.grishka.appkit.api.Callback; +import me.grishka.appkit.api.ErrorResponse; +import me.grishka.appkit.utils.V; + +public class ListMembersFragment extends PaginatedAccountListFragment{ + private static final int ADD_MEMBER_RESULT=600; + + private ImageButton fab; + private FollowList followList; + private boolean inSelectionMode; + private Set selectedAccounts=new HashSet<>(); + private ActionMode actionMode; + private MenuItem deleteItem; + + public ListMembersFragment(){ + setListLayoutId(R.layout.recycler_fragment_with_fab); + } + + @Override + public void onCreate(Bundle savedInstanceState){ + super.onCreate(savedInstanceState); + followList=Parcels.unwrap(getArguments().getParcelable("list")); + setTitle(R.string.list_members); + setHasOptionsMenu(true); + E.register(this); + } + + @Override + public void onDestroy(){ + super.onDestroy(); + E.unregister(this); + } + + @Override + public HeaderPaginationRequest onCreateRequest(String maxID, int count){ + return new GetListAccounts(followList.id, maxID, count); + } + + @Override + protected MastodonAPIRequest loadRemoteInfo(){ + return null; + } + + @Override + public Object getCurrentInfo(){ + return null; + } + + @Override + public String getRemoteDomain(){ + return null; + } + + @Override + protected void onConfigureViewHolder(AccountViewHolder holder){ + super.onConfigureViewHolder(holder); + holder.setStyle(inSelectionMode ? AccountViewHolder.AccessoryType.CHECKBOX : AccountViewHolder.AccessoryType.MENU, false); + holder.setOnClickListener(this::onItemClick); + holder.setOnLongClickListener(this::onItemLongClick); + holder.getContextMenu().getMenu().add(0, R.id.remove_from_list, 0, R.string.remove_from_list); + holder.setOnCustomMenuItemSelectedListener(item->onItemMenuItemSelected(holder, item)); + } + + @Override + protected void onBindViewHolder(AccountViewHolder holder){ + super.onBindViewHolder(holder); + holder.setStyle(inSelectionMode ? AccountViewHolder.AccessoryType.CHECKBOX : AccountViewHolder.AccessoryType.MENU, false); + if(inSelectionMode){ + holder.setChecked(selectedAccounts.contains(holder.getItem().account.id)); + } + } + + @Override + public boolean wantsLightStatusBar(){ + if(actionMode!=null) + return UiUtils.isDarkTheme(); + return super.wantsLightStatusBar(); + } + + @Override + public void onCreateOptionsMenu(Menu menu, MenuInflater inflater){ + inflater.inflate(R.menu.selectable_list, menu); + } + + @Override + public boolean onOptionsItemSelected(MenuItem item){ + int id=item.getItemId(); + if(id==R.id.select){ + enterSelectionMode(); + }else if(id==R.id.select_all){ + for(AccountViewModel a:(ArrayList)data){ + selectedAccounts.add(a.account.id); + } + enterSelectionMode(); + } + return true; + } + + @Override + public void onViewCreated(View view, Bundle savedInstanceState){ + super.onViewCreated(view, savedInstanceState); + fab=view.findViewById(R.id.fab); + fab.setImageResource(R.drawable.ic_fluent_add_24_regular); + fab.setContentDescription(getString(R.string.add_list_member)); + fab.setOnClickListener(v->onFabClick()); + } + + @Override + public void onApplyWindowInsets(WindowInsets insets){ + super.onApplyWindowInsets(insets); + UiUtils.applyBottomInsetToFAB(fab, insets); + } + + @Override + public void onFragmentResult(int reqCode, boolean success, Bundle result){ + if(reqCode==ADD_MEMBER_RESULT && success){ + Account acc=Objects.requireNonNull(Parcels.unwrap(result.getParcelable("selectedAccount"))); + addAccounts(List.of(acc)); + } + } + + @Subscribe + public void onAccountRemovedFromList(AccountRemovedFromListEvent ev){ + if(ev.accountID.equals(accountID) && ev.listID.equals(followList.id)){ + removeAccountRows(Set.of(ev.targetAccountID)); + } + } + + @Subscribe + public void onAccountAddedToList(AccountAddedToListEvent ev){ + if(ev.accountID.equals(accountID) && ev.listID.equals(followList.id)){ + data.add(new AccountViewModel(ev.account, accountID)); + list.getAdapter().notifyItemInserted(data.size()-1); + } + } + + private void onFabClick(){ + Bundle args=new Bundle(); + args.putString("account", accountID); + Nav.goForResult(getActivity(), AddListMembersFragment.class, args, ADD_MEMBER_RESULT, this); + } + + private void onItemClick(AccountViewHolder holder){ + if(inSelectionMode){ + String id=holder.getItem().account.id; + if(selectedAccounts.contains(id)){ + selectedAccounts.remove(id); + holder.setChecked(false); + }else{ + selectedAccounts.add(id); + holder.setChecked(true); + } + updateActionModeTitle(); + deleteItem.setEnabled(!selectedAccounts.isEmpty()); + return; + } + Bundle args=new Bundle(); + args.putString("account", accountID); + args.putParcelable("profileAccount", Parcels.wrap(holder.getItem().account)); + Nav.go(getActivity(), ProfileFragment.class, args); + } + + private boolean onItemLongClick(AccountViewHolder holder){ + if(inSelectionMode) + return false; + selectedAccounts.add(holder.getItem().account.id); + enterSelectionMode(); + return true; + } + + private void onItemMenuItemSelected(AccountViewHolder holder, MenuItem item){ + int id=item.getItemId(); + if(id==R.id.remove_from_list){ + new M3AlertDialogBuilder(getActivity()) + .setTitle(R.string.confirm_remove_list_member) + .setPositiveButton(R.string.remove, (dlg, which)->removeAccounts(Set.of(holder.getItem().account.id))) + .setNegativeButton(R.string.cancel, null) + .show(); + } + } + + private void updateItemsForSelectionModeTransition(){ + list.getAdapter().notifyItemRangeChanged(0, data.size()); + } + + private void enterSelectionMode(){ + inSelectionMode=true; + updateItemsForSelectionModeTransition(); + V.setVisibilityAnimated(fab, View.INVISIBLE); + actionMode=ActionModeHelper.startActionMode(this, ()->elevationOnScrollListener.getCurrentStatusBarColor(), new ActionMode.Callback(){ + @Override + public boolean onCreateActionMode(ActionMode mode, Menu menu){ + return true; + } + + @Override + public boolean onPrepareActionMode(ActionMode mode, Menu menu){ + mode.getMenuInflater().inflate(R.menu.settings_filter_words_action_mode, menu); + deleteItem=menu.findItem(R.id.delete); + return true; + } + + @Override + public boolean onActionItemClicked(ActionMode mode, MenuItem item){ + new M3AlertDialogBuilder(getActivity()) + .setTitle(R.string.confirm_remove_list_members) + .setPositiveButton(R.string.remove, (dlg, which)->removeAccounts(new HashSet<>(selectedAccounts))) + .setNegativeButton(R.string.cancel, null) + .show(); + return true; + } + + @Override + public void onDestroyActionMode(ActionMode mode){ + actionMode=null; + inSelectionMode=false; + selectedAccounts.clear(); + updateItemsForSelectionModeTransition(); + V.setVisibilityAnimated(fab, View.VISIBLE); + } + }); + updateActionModeTitle(); + } + + private void updateActionModeTitle(){ + actionMode.setTitle(getResources().getQuantityString(R.plurals.x_items_selected, selectedAccounts.size(), selectedAccounts.size())); + } + + private void removeAccounts(Set ids){ + new RemoveAccountsFromList(followList.id, ids) + .setCallback(new Callback<>(){ + @Override + public void onSuccess(Void result){ + if(inSelectionMode) + actionMode.finish(); + removeAccountRows(ids); + } + + @Override + public void onError(ErrorResponse error){ + error.showToast(getActivity()); + } + }) + .wrapProgress(getActivity(), R.string.loading, true) + .exec(accountID); + } + + private void addAccounts(Collection accounts){ + new AddAccountsToList(followList.id, accounts.stream().map(a->a.id).collect(Collectors.toSet())) + .setCallback(new Callback<>(){ + @Override + public void onSuccess(Void result){ + for(Account acc:accounts){ + data.add(new AccountViewModel(acc, accountID)); + } + list.getAdapter().notifyItemRangeInserted(data.size()-accounts.size(), accounts.size()); + } + + @Override + public void onError(ErrorResponse error){ + error.showToast(getActivity()); + } + }) + .wrapProgress(getActivity(), R.string.loading, true) + .exec(accountID); + } + + private void removeAccountRows(Set ids){ + for(int i=data.size()-1;i>=0;i--){ + if(ids.contains(((ArrayList)data).get(i).account.id)){ + data.remove(i); + list.getAdapter().notifyItemRemoved(i); + } + } + } + + @Override + public Uri getWebUri(Uri.Builder base){ + return null; + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/ListTimelineFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/ListTimelineFragment.java index fb357d991..514566874 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/ListTimelineFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/ListTimelineFragment.java @@ -20,7 +20,7 @@ import org.joinmastodon.android.api.session.AccountSessionManager; import org.joinmastodon.android.events.ListDeletedEvent; import org.joinmastodon.android.events.ListUpdatedCreatedEvent; import org.joinmastodon.android.model.FilterContext; -import org.joinmastodon.android.model.ListTimeline; +import org.joinmastodon.android.model.FollowList; import org.joinmastodon.android.model.Status; import org.joinmastodon.android.model.TimelineDefinition; import org.joinmastodon.android.ui.M3AlertDialogBuilder; @@ -39,7 +39,7 @@ public class ListTimelineFragment extends PinnableStatusListFragment { private String listID; private String listTitle; @Nullable - private ListTimeline.RepliesPolicy repliesPolicy; + private FollowList.RepliesPolicy repliesPolicy; private boolean exclusive; @Override @@ -54,19 +54,19 @@ public class ListTimelineFragment extends PinnableStatusListFragment { listID = args.getString("listID"); listTitle = args.getString("listTitle"); exclusive = args.getBoolean("listIsExclusive"); - repliesPolicy = ListTimeline.RepliesPolicy.values()[args.getInt("repliesPolicy", 0)]; + repliesPolicy = FollowList.RepliesPolicy.values()[args.getInt("repliesPolicy", 0)]; setTitle(listTitle); setHasOptionsMenu(true); new GetList(listID).setCallback(new Callback<>() { @Override - public void onSuccess(ListTimeline listTimeline) { + public void onSuccess(FollowList followList) { if(getActivity()==null) return; // TODO: save updated info - if (!listTimeline.title.equals(listTitle)) setTitle(listTimeline.title); - if (listTimeline.repliesPolicy != null && !listTimeline.repliesPolicy.equals(repliesPolicy)) { - repliesPolicy = listTimeline.repliesPolicy; + if (!followList.title.equals(listTitle)) setTitle(followList.title); + if (followList.repliesPolicy != null && !followList.repliesPolicy.equals(repliesPolicy)) { + repliesPolicy = followList.repliesPolicy; } } @@ -97,9 +97,9 @@ public class ListTimelineFragment extends PinnableStatusListFragment { .setPositiveButton(R.string.save, (d, which) -> { String newTitle = editor.getTitle().trim(); setTitle(newTitle); - new UpdateList(listID, newTitle, editor.isExclusive(), editor.getRepliesPolicy()).setCallback(new Callback<>() { + new UpdateList(listID, newTitle, editor.getRepliesPolicy(), editor.isExclusive()).setCallback(new Callback<>() { @Override - public void onSuccess(ListTimeline list) { + public void onSuccess(FollowList list) { if(getActivity()==null) return; setTitle(list.title); listTitle = list.title; @@ -119,7 +119,7 @@ public class ListTimelineFragment extends PinnableStatusListFragment { .show(); } else if (item.getItemId() == R.id.delete) { UiUtils.confirmDeleteList(getActivity(), accountID, listID, listTitle, () -> { - E.post(new ListDeletedEvent(listID)); + E.post(new ListDeletedEvent(accountID, listID)); Nav.finish(this); }); } diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/ListsFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/ListsFragment.java index 8e92bb5ea..e996830b4 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/ListsFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/ListsFragment.java @@ -24,7 +24,7 @@ import org.joinmastodon.android.api.requests.lists.GetLists; import org.joinmastodon.android.api.requests.lists.RemoveAccountsFromList; import org.joinmastodon.android.events.ListDeletedEvent; import org.joinmastodon.android.events.ListUpdatedCreatedEvent; -import org.joinmastodon.android.model.ListTimeline; +import org.joinmastodon.android.model.FollowList; import org.joinmastodon.android.ui.DividerItemDecoration; import org.joinmastodon.android.ui.M3AlertDialogBuilder; import org.joinmastodon.android.ui.views.ListEditor; @@ -42,7 +42,7 @@ import me.grishka.appkit.api.SimpleCallback; import me.grishka.appkit.utils.BindableViewHolder; import me.grishka.appkit.views.UsableRecyclerView; -public class ListsFragment extends MastodonRecyclerFragment implements ScrollableToTop, ProvidesAssistContent.ProvidesWebUri { +public class ListsFragment extends MastodonRecyclerFragment implements ScrollableToTop, ProvidesAssistContent.ProvidesWebUri { private String accountID; private String profileAccountId; private final HashMap userInListBefore = new HashMap<>(); @@ -97,9 +97,9 @@ public class ListsFragment extends MastodonRecyclerFragment implem .setIcon(R.drawable.ic_fluent_people_add_28_regular) .setView(editor) .setPositiveButton(R.string.sk_create, (d, which) -> - new CreateList(editor.getTitle(), editor.isExclusive(), editor.getRepliesPolicy()).setCallback(new Callback<>() { + new CreateList(editor.getTitle(), editor.getRepliesPolicy(), editor.isExclusive()).setCallback(new Callback<>() { @Override - public void onSuccess(ListTimeline list) { + public void onSuccess(FollowList list) { data.add(0, list); adapter.notifyItemRangeInserted(0, 1); E.post(new ListUpdatedCreatedEvent(list.id, list.title, list.exclusive, list.repliesPolicy)); @@ -120,16 +120,16 @@ public class ListsFragment extends MastodonRecyclerFragment implem private void saveListMembership(String listId, boolean isMember) { userInList.put(listId, isMember); List accountIdList = Collections.singletonList(profileAccountId); - MastodonAPIRequest req = isMember ? new AddAccountsToList(listId, accountIdList) : new RemoveAccountsFromList(listId, accountIdList); - req.setCallback(new Callback<>() { - @Override - public void onSuccess(Object o) {} - - @Override - public void onError(ErrorResponse error) { - error.showToast(getContext()); - } - }).exec(accountID); +// MastodonAPIRequest req = (MastodonAPIRequest) (isMember ? new AddAccountsToList(listId, accountIdList) : new RemoveAccountsFromList(listId, accountIdList)); +// req.setCallback(new Callback<>() { +// @Override +// public void onSuccess(Object o) {} +// +// @Override +// public void onError(ErrorResponse error) { +// error.showToast(getContext()); +// } +// }).exec(accountID); } @Override @@ -139,19 +139,19 @@ public class ListsFragment extends MastodonRecyclerFragment implem currentRequest=(profileAccountId != null ? new GetLists(profileAccountId) : new GetLists()) .setCallback(new SimpleCallback<>(this) { @Override - public void onSuccess(List lists) { + public void onSuccess(List lists) { if(getActivity()==null) return; - for (ListTimeline l : lists) userInListBefore.put(l.id, true); + for (FollowList l : lists) userInListBefore.put(l.id, true); userInList.putAll(userInListBefore); if (profileAccountId == null || !lists.isEmpty()) onDataLoaded(lists, false); if (profileAccountId == null) return; currentRequest=new GetLists().setCallback(new SimpleCallback<>(ListsFragment.this) { @Override - public void onSuccess(List allLists) { + public void onSuccess(List allLists) { if(getActivity()==null) return; - List newLists = new ArrayList<>(); - for (ListTimeline l : allLists) { + List newLists = new ArrayList<>(); + for (FollowList l : allLists) { if (lists.stream().noneMatch(e -> e.id.equals(l.id))) newLists.add(l); if (!userInListBefore.containsKey(l.id)) { userInListBefore.put(l.id, false); @@ -169,8 +169,8 @@ public class ListsFragment extends MastodonRecyclerFragment implem @Subscribe public void onListDeletedEvent(ListDeletedEvent event) { for (int i = 0; i < data.size(); i++) { - ListTimeline item = data.get(i); - if (item.id.equals(event.id)) { + FollowList item = data.get(i); + if (item.id.equals(event.listID)) { data.remove(i); adapter.notifyItemRemoved(i); break; @@ -181,7 +181,7 @@ public class ListsFragment extends MastodonRecyclerFragment implem @Subscribe public void onListUpdatedCreatedEvent(ListUpdatedCreatedEvent event) { for (int i = 0; i < data.size(); i++) { - ListTimeline item = data.get(i); + FollowList item = data.get(i); if (item.id.equals(event.id)) { item.title = event.title; item.repliesPolicy = event.repliesPolicy; @@ -230,7 +230,7 @@ public class ListsFragment extends MastodonRecyclerFragment implem } } - private class ListViewHolder extends BindableViewHolder implements UsableRecyclerView.Clickable{ + private class ListViewHolder extends BindableViewHolder implements UsableRecyclerView.Clickable{ private final TextView title; private final CheckBox listToggle; @@ -241,7 +241,7 @@ public class ListsFragment extends MastodonRecyclerFragment implem } @Override - public void onBind(ListTimeline item) { + public void onBind(FollowList item) { title.setText(item.title); title.setCompoundDrawablesRelativeWithIntrinsicBounds(itemView.getContext().getDrawable( item.exclusive ? R.drawable.ic_fluent_rss_24_regular : R.drawable.ic_fluent_people_24_regular diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/ManageFollowedHashtagsFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/ManageFollowedHashtagsFragment.java new file mode 100644 index 000000000..e83aa5c5b --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/ManageFollowedHashtagsFragment.java @@ -0,0 +1,95 @@ +package org.joinmastodon.android.fragments; + +import android.os.Bundle; +import android.view.Menu; +import android.view.MenuItem; + +import org.joinmastodon.android.R; +import org.joinmastodon.android.api.requests.tags.GetFollowedTags; +import org.joinmastodon.android.api.requests.tags.SetTagFollowed; +import org.joinmastodon.android.fragments.settings.BaseSettingsFragment; +import org.joinmastodon.android.model.Hashtag; +import org.joinmastodon.android.model.HeaderPaginationList; +import org.joinmastodon.android.model.viewmodel.ListItemWithOptionsMenu; +import org.joinmastodon.android.ui.M3AlertDialogBuilder; +import org.joinmastodon.android.ui.utils.UiUtils; + +import java.util.stream.Collectors; + +import me.grishka.appkit.api.Callback; +import me.grishka.appkit.api.ErrorResponse; +import me.grishka.appkit.api.SimpleCallback; + +public class ManageFollowedHashtagsFragment extends BaseSettingsFragment implements ListItemWithOptionsMenu.OptionsMenuListener{ + private String maxID; + + public ManageFollowedHashtagsFragment(){ + super(100); + } + + @Override + public void onCreate(Bundle savedInstanceState){ + super.onCreate(savedInstanceState); + setTitle(R.string.manage_hashtags); + loadData(); + } + + @Override + protected void doLoadData(int offset, int count){ + currentRequest=new GetFollowedTags(offset>0 ? maxID : null, count) + .setCallback(new SimpleCallback<>(this){ + @Override + public void onSuccess(HeaderPaginationList result){ + maxID=null; + if(result.nextPageUri!=null) + maxID=result.nextPageUri.getQueryParameter("max_id"); + onDataLoaded(result.stream().map(t->{ + int posts=t.getWeekPosts(); + return new ListItemWithOptionsMenu<>(t.name, getResources().getQuantityString(R.plurals.x_posts_recently, posts, posts), ManageFollowedHashtagsFragment.this, + R.drawable.ic_fluent_tag_24_regular, ManageFollowedHashtagsFragment.this::onItemClick, t, false); + }).collect(Collectors.toList()), maxID!=null); + } + }) + .exec(accountID); + } + + @Override + public void onConfigureListItemOptionsMenu(ListItemWithOptionsMenu item, Menu menu){ + menu.clear(); + menu.add(getString(R.string.unfollow_user, "#"+item.parentObject.name)); + } + + @Override + public void onListItemOptionSelected(ListItemWithOptionsMenu item, MenuItem menuItem){ + new M3AlertDialogBuilder(getActivity()) + .setTitle(getString(R.string.unfollow_confirmation, "#"+item.parentObject.name)) + .setPositiveButton(R.string.unfollow, (dlg, which)->doUnfollow(item)) + .setNegativeButton(R.string.cancel, null) + .show(); + } + + private void onItemClick(ListItemWithOptionsMenu item){ + UiUtils.openHashtagTimeline(getActivity(), accountID, item.parentObject); + } + + private void doUnfollow(ListItemWithOptionsMenu item){ + new SetTagFollowed(item.parentObject.name, false) + .setCallback(new Callback<>(){ + @Override + public void onSuccess(Hashtag result){ + int index=data.indexOf(item); + if(index==-1) + return; + data.remove(index); + list.getAdapter().notifyItemRemoved(index); + } + + @Override + public void onError(ErrorResponse error){ + error.showToast(getActivity()); + } + }) + .wrapProgress(getActivity(), R.string.loading, true) + .exec(accountID); + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/ManageListsFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/ManageListsFragment.java new file mode 100644 index 000000000..796064d98 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/ManageListsFragment.java @@ -0,0 +1,199 @@ +package org.joinmastodon.android.fragments; + +import android.app.Activity; +import android.os.Bundle; +import android.view.Menu; +import android.view.MenuItem; +import android.view.View; +import android.view.WindowInsets; +import android.widget.ImageButton; + +import com.squareup.otto.Subscribe; + +import org.joinmastodon.android.E; +import org.joinmastodon.android.R; +import org.joinmastodon.android.api.requests.lists.DeleteList; +import org.joinmastodon.android.api.session.AccountSessionManager; +import org.joinmastodon.android.events.ListCreatedEvent; +import org.joinmastodon.android.events.ListDeletedEvent; +import org.joinmastodon.android.events.ListUpdatedEvent; +import org.joinmastodon.android.fragments.settings.BaseSettingsFragment; +import org.joinmastodon.android.model.FollowList; +import org.joinmastodon.android.model.viewmodel.ListItem; +import org.joinmastodon.android.model.viewmodel.ListItemWithOptionsMenu; +import org.joinmastodon.android.ui.M3AlertDialogBuilder; +import org.joinmastodon.android.ui.utils.UiUtils; +import org.parceler.Parcels; + +import java.util.Comparator; +import java.util.List; +import java.util.stream.Collectors; + +import me.grishka.appkit.Nav; +import me.grishka.appkit.api.Callback; +import me.grishka.appkit.api.ErrorResponse; +import me.grishka.appkit.api.SimpleCallback; + +public class ManageListsFragment extends BaseSettingsFragment implements ListItemWithOptionsMenu.OptionsMenuListener{ + private ImageButton fab; + + public ManageListsFragment(){ + setListLayoutId(R.layout.recycler_fragment_with_fab); + } + + @Override + public void onCreate(Bundle savedInstanceState){ + super.onCreate(savedInstanceState); + setTitle(R.string.manage_lists); + loadData(); + setRefreshEnabled(true); + E.register(this); + } + + @Override + public void onDestroy(){ + super.onDestroy(); + E.unregister(this); + } + + @Override + protected void doLoadData(int offset, int count){ + Callback> callback=new SimpleCallback<>(this){ + @Override + public void onSuccess(List result){ + onDataLoaded(result.stream().map(ManageListsFragment.this::makeItem).collect(Collectors.toList()), false); + } + }; + if(refreshing){ + AccountSessionManager.get(accountID) + .getCacheController() + .reloadLists(callback); + }else{ + AccountSessionManager.get(accountID) + .getCacheController() + .getLists(callback); + } + } + + private ListItem makeItem(FollowList l){ + return new ListItemWithOptionsMenu<>(l.title, null, ManageListsFragment.this, R.drawable.ic_list_alt_24px, ManageListsFragment.this::onListClick, l, false); + } + + private void onListClick(ListItemWithOptionsMenu item){ + Bundle args=new Bundle(); + args.putString("account", accountID); + args.putParcelable("list", Parcels.wrap(item.parentObject)); + Nav.go(getActivity(), ListTimelineFragment.class, args); + } + + @Override + public void onConfigureListItemOptionsMenu(ListItemWithOptionsMenu item, Menu menu){ + menu.add(0, R.id.edit, 0, R.string.edit_list); + menu.add(0, R.id.delete, 1, R.string.delete_list); + } + + @Override + public void onListItemOptionSelected(ListItemWithOptionsMenu item, MenuItem menuItem){ + int id=menuItem.getItemId(); + if(id==R.id.edit){ + Bundle args=new Bundle(); + args.putString("account", accountID); + args.putParcelable("list", Parcels.wrap(item.parentObject)); + Nav.go(getActivity(), EditListFragment.class, args); + }else if(id==R.id.delete){ + new M3AlertDialogBuilder(getActivity()) + .setTitle(R.string.delete_list) + .setMessage(getString(R.string.delete_list_confirm, item.parentObject.title)) + .setPositiveButton(R.string.delete, (dlg, which)->doDeleteList(item.parentObject)) + .setNegativeButton(R.string.cancel, null) + .show(); + } + } + + @Override + public void onViewCreated(View view, Bundle savedInstanceState){ + super.onViewCreated(view, savedInstanceState); + fab=view.findViewById(R.id.fab); + fab.setImageResource(R.drawable.ic_fluent_add_24_regular); + fab.setContentDescription(getString(R.string.create_list)); + fab.setOnClickListener(v->onFabClick()); + } + + @Override + public void onApplyWindowInsets(WindowInsets insets){ + super.onApplyWindowInsets(insets); + UiUtils.applyBottomInsetToFAB(fab, insets); + } + + private void doDeleteList(FollowList list){ + new DeleteList(list.id) + .setCallback(new Callback<>(){ + @Override + public void onSuccess(Void result){ + for(int i=0;i item:data){ + if(item.parentObject.id.equals(ev.list.id)){ + item.parentObject=ev.list; + item.title=ev.list.title; + rebindItem(item); + break; + } + } + } + + @Subscribe + public void onListDeleted(ListDeletedEvent ev){ + if(!ev.accountID.equals(accountID)) + return; + int i=0; + for(ListItem item:data){ + if(item.parentObject.id.equals(ev.listID)){ + data.remove(i); + itemsAdapter.notifyItemRemoved(i); + break; + } + i++; + } + } + + @Subscribe + public void onListCreated(ListCreatedEvent ev){ + if(!ev.accountID.equals(accountID)) + return; + ListItem item=makeItem(ev.list); + data.add(item); + ((List>)data).sort(Comparator.comparing(l->l.parentObject.title)); + itemsAdapter.notifyItemInserted(data.indexOf(item)); + } + + private void onFabClick(){ + Bundle args=new Bundle(); + args.putString("account", accountID); + Nav.go(getActivity(), CreateListFragment.class, args); + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/MastodonRecyclerFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/MastodonRecyclerFragment.java index fc805457a..746bb77b0 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/MastodonRecyclerFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/MastodonRecyclerFragment.java @@ -7,6 +7,7 @@ import android.widget.TextView; import android.widget.Toolbar; import org.joinmastodon.android.R; +import org.joinmastodon.android.ui.BetterItemAnimator; import org.joinmastodon.android.ui.utils.UiUtils; import org.joinmastodon.android.utils.ElevationOnScrollListener; diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/NotificationsListFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/NotificationsListFragment.java index 7ad134b7e..6c8b9f791 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/NotificationsListFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/NotificationsListFragment.java @@ -7,6 +7,10 @@ import android.graphics.Rect; import android.net.Uri; import android.os.Bundle; import android.text.TextUtils; +import android.view.LayoutInflater; +import android.view.Menu; +import android.view.MenuInflater; +import android.view.MenuItem; import android.view.View; import com.squareup.otto.Subscribe; @@ -14,6 +18,7 @@ import com.squareup.otto.Subscribe; import org.joinmastodon.android.E; import org.joinmastodon.android.GlobalUserPreferences; import org.joinmastodon.android.R; +import org.joinmastodon.android.api.requests.markers.SaveMarkers; import org.joinmastodon.android.api.session.AccountSessionManager; import org.joinmastodon.android.events.EmojiReactionsUpdatedEvent; import org.joinmastodon.android.events.PollUpdatedEvent; @@ -47,12 +52,13 @@ import androidx.recyclerview.widget.RecyclerView; import me.grishka.appkit.api.SimpleCallback; import me.grishka.appkit.utils.MergeRecyclerAdapter; -public class NotificationsListFragment extends BaseStatusListFragment { +public class NotificationsListFragment extends BaseStatusListFragment{ private boolean onlyMentions; - private boolean onlyPosts; private String maxID; private boolean reloadingFromCache; private DiscoverInfoBannerHelper bannerHelper; + private String unreadMarker, realUnreadMarker; + private MenuItem markAllReadItem; @Override protected boolean wantsComposeButton() { @@ -63,13 +69,8 @@ public class NotificationsListFragment extends BaseStatusListFragment buildDisplayItems(Notification n){ - if(!onlyMentions && !onlyPosts){ + if(!onlyMentions){ switch(n.type){ case MENTION -> { if(!getLocalPrefs().notificationFilters.mention) @@ -164,7 +163,7 @@ public class NotificationsListFragment extends BaseStatusListFragment0 ? maxID : null, count, onlyMentions, onlyPosts, refreshing && !reloadingFromCache, new SimpleCallback<>(this){ + .getNotifications(offset>0 ? maxID : null, count, onlyMentions, false, refreshing && !reloadingFromCache, new SimpleCallback<>(this){ @Override public void onSuccess(PaginatedResponse> result){ if(getActivity()==null) @@ -186,7 +185,7 @@ public class NotificationsListFragment extends BaseStatusListFragment=adapter.getItemCount()); } + @Override + public void onCreateOptionsMenu(Menu menu, MenuInflater inflater){ + inflater.inflate(R.menu.notifications, menu); + markAllReadItem=menu.findItem(R.id.mark_all_read); + } + + @Override + public boolean onOptionsItemSelected(MenuItem item){ + if(item.getItemId()==R.id.mark_all_read){ + markAsRead(); + resetUnreadBackground(); + } + return true; + } + + private void markAsRead(){ + if(data.isEmpty()) + return; + String id=data.get(0).id; + if(ObjectIdComparator.INSTANCE.compare(id, realUnreadMarker)>0){ + new SaveMarkers(null, id).exec(accountID); + AccountSessionManager.get(accountID).setNotificationsMarker(id, true); + realUnreadMarker=id; + } + } + void resetUnreadBackground(){ if (getParentFragment() instanceof NotificationsFragment nf) { nf.unreadMarker=nf.realUnreadMarker; @@ -396,13 +412,20 @@ public class NotificationsListFragment extends BaseStatusListFragment{ nf.unreadMarker=nf.realUnreadMarker=m; nf.updateMarkAllReadButton(); }); } resetUnreadBackground(); + AccountSessionManager.get(accountID).reloadNotificationsMarker(m->{ + unreadMarker=realUnreadMarker=m; + }); + } + + private void updateMarkAllReadButton(){ + markAllReadItem.setEnabled(!data.isEmpty() && realUnreadMarker!=null && !realUnreadMarker.equals(data.get(0).id)); } @Override @@ -420,4 +443,20 @@ public class NotificationsListFragment extends BaseStatusListFragment=0;i--){ + if(list.getChildViewHolder(list.getChildAt(i)) instanceof StatusDisplayItem.Holder itemHolder){ + String id=itemHolder.getItemID(); + for(int j=0;jcurrentPhotoViewer=null, ()->ava, null, null)); + null, accountID, new SingleImagePhotoViewerListener(avatar, avatarBorder, new int[]{radius, radius, radius, radius}, this, ()->currentPhotoViewer=null, ()->ava, null, null)); } } @@ -1376,7 +1377,7 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList if(drawable==null || drawable instanceof ColorDrawable) return; currentPhotoViewer=new PhotoViewer(getActivity(), createFakeAttachments(account.header, drawable), 0, - new SingleImagePhotoViewerListener(cover, cover, null, this, ()->currentPhotoViewer=null, ()->drawable, ()->avatarBorder.setTranslationZ(2), ()->avatarBorder.setTranslationZ(0))); + null, accountID, new SingleImagePhotoViewerListener(cover, cover, null, this, ()->currentPhotoViewer=null, ()->drawable, ()->avatarBorder.setTranslationZ(2), ()->avatarBorder.setTranslationZ(0))); } } diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/SplashFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/SplashFragment.java index 05622a216..d1326c589 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/SplashFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/SplashFragment.java @@ -1,7 +1,12 @@ package org.joinmastodon.android.fragments; +import android.app.ProgressDialog; +import android.content.ClipData; +import android.content.ClipboardManager; import android.graphics.drawable.ColorDrawable; +import android.net.Uri; import android.os.Bundle; +import android.text.TextUtils; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; @@ -11,6 +16,8 @@ import android.widget.ProgressBar; import org.joinmastodon.android.MastodonApp; import org.joinmastodon.android.R; +import org.joinmastodon.android.api.MastodonErrorResponse; +import org.joinmastodon.android.api.requests.accounts.CheckInviteLink; import org.joinmastodon.android.api.requests.catalog.GetCatalogDefaultInstances; import org.joinmastodon.android.api.requests.instance.GetInstance; import org.joinmastodon.android.fragments.onboarding.InstanceCatalogSignupFragment; @@ -20,6 +27,7 @@ import org.joinmastodon.android.model.Instance; import org.joinmastodon.android.model.catalog.CatalogDefaultInstance; import org.joinmastodon.android.ui.InterpolatingMotionEffect; import org.joinmastodon.android.ui.M3AlertDialogBuilder; +import org.joinmastodon.android.ui.text.HtmlParser; import org.joinmastodon.android.ui.utils.UiUtils; import org.joinmastodon.android.ui.views.ProgressBarButton; import org.joinmastodon.android.ui.views.SizeListenerFrameLayout; @@ -48,6 +56,9 @@ public class SplashFragment extends AppKitFragment{ private ProgressBar defaultServerProgress; private String chosenDefaultServer=DEFAULT_SERVER; private boolean loadingDefaultServer, loadedDefaultServer; + private Uri currentInviteLink; + private ProgressDialog instanceLoadingProgress; + private String inviteCode; @Override public void onCreate(Bundle savedInstanceState){ @@ -110,19 +121,65 @@ public class SplashFragment extends AppKitFragment{ Bundle extras=new Bundle(); boolean isSignup=v.getId()==R.id.btn_get_started; extras.putBoolean("signup", isSignup); + extras.putString("defaultServer", chosenDefaultServer); Nav.go(getActivity(), isSignup ? InstanceCatalogSignupFragment.class : InstanceChooserLoginFragment.class, extras); } private void onJoinDefaultServerClick(View v){ if(loadingDefaultServer) return; + instanceLoadingProgress=new ProgressDialog(getActivity()); + instanceLoadingProgress.setCancelable(false); + instanceLoadingProgress.setMessage(getString(R.string.loading_instance)); + instanceLoadingProgress.show(); + if(currentInviteLink!=null){ + new CheckInviteLink(currentInviteLink.getPath()) + .setCallback(new Callback<>(){ + @Override + public void onSuccess(CheckInviteLink.Response result){ + inviteCode=result.inviteCode; + proceedWithServerDomain(currentInviteLink.getHost()); + } + + @Override + public void onError(ErrorResponse error){ + if(getActivity()==null) + return; + instanceLoadingProgress.dismiss(); + instanceLoadingProgress=null; + if(error instanceof MastodonErrorResponse mer){ + switch(mer.httpStatus){ + case 401 -> new M3AlertDialogBuilder(getActivity()) + .setTitle(R.string.expired_invite_link) + .setMessage(getString(R.string.expired_clipboard_invite_link_alert, currentInviteLink.getHost(), chosenDefaultServer)) + .setPositiveButton(R.string.ok, null) + .show(); + case 404 -> new M3AlertDialogBuilder(getActivity()) + .setTitle(R.string.invalid_invite_link) + .setMessage(getString(R.string.invalid_clipboard_invite_link_alert, currentInviteLink.getHost(), chosenDefaultServer)) + .setPositiveButton(R.string.ok, null) + .show(); + default -> error.showToast(getActivity()); + } + } + } + }) + .execNoAuth(currentInviteLink.getHost()); + return; + } + proceedWithServerDomain(chosenDefaultServer); + } + + private void proceedWithServerDomain(String domain){ new GetInstance() .setCallback(new Callback<>(){ @Override public void onSuccess(Instance result){ if(getActivity()==null) return; - if(!result.registrations){ + instanceLoadingProgress.dismiss(); + instanceLoadingProgress=null; + if(!result.registrations && TextUtils.isEmpty(inviteCode)){ new M3AlertDialogBuilder(getActivity()) .setTitle(R.string.error) .setMessage(R.string.instance_signup_closed) @@ -132,6 +189,8 @@ public class SplashFragment extends AppKitFragment{ } Bundle args=new Bundle(); args.putParcelable("instance", Parcels.wrap(result)); + if(inviteCode!=null) + args.putString("inviteCode", inviteCode); Nav.go(getActivity(), InstanceRulesFragment.class, args); } @@ -139,11 +198,12 @@ public class SplashFragment extends AppKitFragment{ public void onError(ErrorResponse error){ if(getActivity()==null) return; + instanceLoadingProgress.dismiss(); + instanceLoadingProgress=null; error.showToast(getActivity()); } }) - .wrapProgress(getActivity(), R.string.loading_instance, true) - .execNoAuth(chosenDefaultServer); + .execNoAuth(domain); } private void onLearnMoreClick(View v){ @@ -198,7 +258,18 @@ public class SplashFragment extends AppKitFragment{ } private void loadAndChooseDefaultServer(){ - loadingDefaultServer=true; + ClipData clipData=getActivity().getSystemService(ClipboardManager.class).getPrimaryClip(); + if(clipData!=null && clipData.getItemCount()>0){ + CharSequence clipText=clipData.getItemAt(0).coerceToText(getActivity()); + if(HtmlParser.INVITE_LINK_PATTERN.matcher(clipText).find()){ + currentInviteLink=Uri.parse(clipText.toString()); + defaultServerButton.setText(getString(R.string.join_server_x_with_invite, currentInviteLink.getHost())); + } + }else{ + loadingDefaultServer=true; + defaultServerButton.setTextVisible(false); + defaultServerProgress.setVisibility(View.VISIBLE); + } new GetCatalogDefaultInstances() .setCallback(new Callback<>(){ @Override @@ -241,7 +312,7 @@ public class SplashFragment extends AppKitFragment{ chosenDefaultServer=domain; loadingDefaultServer=false; loadedDefaultServer=true; - if(defaultServerButton!=null && getActivity()!=null){ + if(defaultServerButton!=null && getActivity()!=null && currentInviteLink==null){ defaultServerButton.setTextVisible(true); defaultServerProgress.setVisibility(View.GONE); defaultServerButton.setText(getString(R.string.join_default_server, chosenDefaultServer)); diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/ThreadFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/ThreadFragment.java index d186059df..6bd70b5cd 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/ThreadFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/ThreadFragment.java @@ -2,11 +2,19 @@ package org.joinmastodon.android.fragments; import android.net.Uri; import android.os.Bundle; +import android.text.TextUtils; import android.view.View; +import android.view.ViewGroup; +import android.view.WindowInsets; +import android.widget.FrameLayout; +import android.widget.ImageView; +import android.widget.LinearLayout; +import android.widget.TextView; import androidx.annotation.NonNull; import androidx.recyclerview.widget.RecyclerView; +import org.joinmastodon.android.GlobalUserPreferences; import com.squareup.otto.Subscribe; import org.joinmastodon.android.E; @@ -23,6 +31,7 @@ import org.joinmastodon.android.model.Account; import org.joinmastodon.android.model.FilterContext; import org.joinmastodon.android.model.Status; import org.joinmastodon.android.model.StatusContext; +import org.joinmastodon.android.ui.OutlineProviders; import org.joinmastodon.android.ui.BetterItemAnimator; import org.joinmastodon.android.ui.displayitems.ExtendedFooterStatusDisplayItem; import org.joinmastodon.android.ui.displayitems.FooterStatusDisplayItem; @@ -50,7 +59,13 @@ import java.util.stream.Collectors; import me.grishka.appkit.api.Callback; import me.grishka.appkit.api.ErrorResponse; +import androidx.recyclerview.widget.RecyclerView; +import me.grishka.appkit.Nav; import me.grishka.appkit.api.SimpleCallback; +import me.grishka.appkit.imageloader.ViewImageLoader; +import me.grishka.appkit.imageloader.requests.UrlImageLoaderRequest; +import me.grishka.appkit.utils.MergeRecyclerAdapter; +import me.grishka.appkit.utils.SingleViewRecyclerAdapter; import me.grishka.appkit.utils.V; public class ThreadFragment extends StatusListFragment implements ProvidesAssistContent { @@ -58,10 +73,16 @@ public class ThreadFragment extends StatusListFragment implements ProvidesAssist private final HashMap ancestryMap = new HashMap<>(); private StatusContext result; protected boolean contextInitiallyRendered, transitionFinished, preview; + private FrameLayout replyContainer; + private LinearLayout replyButton; + private ImageView replyButtonAva; + private TextView replyButtonText; + private int lastBottomInset; @Override public void onCreate(Bundle savedInstanceState){ super.onCreate(savedInstanceState); + setLayout(R.layout.fragment_thread); mainStatus=Parcels.unwrap(getArguments().getParcelable("status")); replyTo=Parcels.unwrap(getArguments().getParcelable("inReplyTo")); Account inReplyToAccount=Parcels.unwrap(getArguments().getParcelable("inReplyToAccount")); @@ -143,7 +164,7 @@ public class ThreadFragment extends StatusListFragment implements ProvidesAssist } } } - + for (int deleteThisItem : deleteTheseItems) itemsToModify.remove(deleteThisItem); if(s.id.equals(mainStatus.id)) { items.add(new ExtendedFooterStatusDisplayItem(s.id, this, accountID, s.getContentStatus())); @@ -387,6 +408,20 @@ public class ThreadFragment extends StatusListFragment implements ProvidesAssist @Override public void onViewCreated(View view, Bundle savedInstanceState){ super.onViewCreated(view, savedInstanceState); + replyContainer=view.findViewById(R.id.reply_button_wrapper); + replyButton=replyContainer.findViewById(R.id.reply_button); + replyButtonText=replyButton.findViewById(R.id.reply_btn_text); + replyButtonAva=replyButton.findViewById(R.id.avatar); + replyButton.setOutlineProvider(OutlineProviders.roundedRect(20)); + replyButton.setClipToOutline(true); + replyButtonText.setText(getString(R.string.reply_to_user, mainStatus.account.displayName)); + replyButtonAva.setOutlineProvider(OutlineProviders.OVAL); + replyButtonAva.setClipToOutline(true); + replyButton.setOnClickListener(v->openReply()); + Account self=AccountSessionManager.get(accountID).self; + if(!TextUtils.isEmpty(self.avatar)){ + ViewImageLoader.loadWithoutAnimation(replyButtonAva, getResources().getDrawable(R.drawable.image_placeholder), new UrlImageLoaderRequest(self.avatar, V.dp(24), V.dp(24))); + } UiUtils.loadCustomEmojiInTextView(toolbarTitleView); showContent(); if(!loaded) @@ -526,4 +561,24 @@ public class ThreadFragment extends StatusListFragment implements ProvidesAssist } super.onErrorRetryClick(); } + + @Override + public void onApplyWindowInsets(WindowInsets insets){ + lastBottomInset=insets.getSystemWindowInsetBottom(); + super.onApplyWindowInsets(UiUtils.applyBottomInsetToFixedView(replyContainer, insets)); + } + + private void openReply(){ + 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(){ + return replyContainer.getHeight()-lastBottomInset; + } } diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/account_list/ComposeAccountSearchFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/account_list/AccountSearchFragment.java similarity index 81% rename from mastodon/src/main/java/org/joinmastodon/android/fragments/account_list/ComposeAccountSearchFragment.java rename to mastodon/src/main/java/org/joinmastodon/android/fragments/account_list/AccountSearchFragment.java index 965c2d49c..ce3579a8b 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/account_list/ComposeAccountSearchFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/account_list/AccountSearchFragment.java @@ -6,7 +6,9 @@ import android.text.TextUtils; import android.view.View; import org.joinmastodon.android.R; +import org.joinmastodon.android.api.MastodonAPIRequest; import org.joinmastodon.android.api.requests.search.GetSearchResults; +import org.joinmastodon.android.model.Account; import org.joinmastodon.android.model.SearchResults; import org.joinmastodon.android.model.viewmodel.AccountViewModel; import org.joinmastodon.android.ui.SearchViewHelper; @@ -14,13 +16,14 @@ import org.joinmastodon.android.ui.utils.UiUtils; import org.joinmastodon.android.ui.viewholders.AccountViewHolder; import org.parceler.Parcels; +import java.util.List; import java.util.stream.Collectors; import me.grishka.appkit.Nav; import me.grishka.appkit.api.SimpleCallback; -public class ComposeAccountSearchFragment extends BaseAccountListFragment{ - private String currentQuery; +public class AccountSearchFragment extends BaseAccountListFragment{ + protected String currentQuery; private boolean resultDelivered; private SearchViewHelper searchViewHelper; @@ -29,12 +32,11 @@ public class ComposeAccountSearchFragment extends BaseAccountListFragment{ super.onCreate(savedInstanceState); setRefreshEnabled(false); setEmptyText(""); - dataLoaded(); } @Override public void onViewCreated(View view, Bundle savedInstanceState){ - searchViewHelper=new SearchViewHelper(getActivity(), getToolbarContext(), getString(R.string.search_hint)); + searchViewHelper=new SearchViewHelper(getActivity(), getToolbarContext(), getSearchViewPlaceholder()); searchViewHelper.setListeners(this::onQueryChanged, null); searchViewHelper.addDivider(contentView); super.onViewCreated(view, savedInstanceState); @@ -52,13 +54,21 @@ public class ComposeAccountSearchFragment extends BaseAccountListFragment{ .setCallback(new SimpleCallback<>(this){ @Override public void onSuccess(SearchResults result){ - setEmptyText(R.string.no_search_results); - onDataLoaded(result.accounts.stream().map(a->new AccountViewModel(a, accountID)).collect(Collectors.toList()), false); + AccountSearchFragment.this.onSuccess(result.accounts); } }) .exec(accountID); } + protected void onSuccess(List result){ + setEmptyText(R.string.no_search_results); + onDataLoaded(result.stream().map(a->new AccountViewModel(a, accountID)).collect(Collectors.toList()), false); + } + + protected String getSearchViewPlaceholder(){ + return getString(R.string.search_hint); + } + @Override protected void onUpdateToolbar(){ super.onUpdateToolbar(); diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/account_list/AddListMembersFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/account_list/AddListMembersFragment.java new file mode 100644 index 000000000..28afce3e2 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/account_list/AddListMembersFragment.java @@ -0,0 +1,37 @@ +package org.joinmastodon.android.fragments.account_list; + +import android.os.Bundle; + +import org.joinmastodon.android.R; +import org.joinmastodon.android.api.requests.accounts.SearchAccounts; +import org.joinmastodon.android.model.Account; + +import java.util.List; + +import me.grishka.appkit.api.SimpleCallback; + +public class AddListMembersFragment extends AccountSearchFragment{ + @Override + public void onCreate(Bundle savedInstanceState){ + super.onCreate(savedInstanceState); + dataLoaded(); + } + + @Override + protected void doLoadData(int offset, int count){ + refreshing=true; + currentRequest=new SearchAccounts(currentQuery, 0, 0, false, true) + .setCallback(new SimpleCallback<>(this){ + @Override + public void onSuccess(List result){ + AddListMembersFragment.this.onSuccess(result); + } + }) + .exec(accountID); + } + + @Override + protected String getSearchViewPlaceholder(){ + return getString(R.string.search_among_people_you_follow); + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/account_list/AddNewListMembersFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/account_list/AddNewListMembersFragment.java new file mode 100644 index 000000000..802318b9d --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/account_list/AddNewListMembersFragment.java @@ -0,0 +1,125 @@ +package org.joinmastodon.android.fragments.account_list; + +import android.annotation.SuppressLint; +import android.content.res.TypedArray; +import android.os.Bundle; +import android.text.TextUtils; +import android.widget.Button; + +import org.joinmastodon.android.R; +import org.joinmastodon.android.api.requests.accounts.GetAccountFollowing; +import org.joinmastodon.android.api.requests.accounts.SearchAccounts; +import org.joinmastodon.android.api.session.AccountSessionManager; +import org.joinmastodon.android.model.Account; +import org.joinmastodon.android.model.HeaderPaginationList; +import org.joinmastodon.android.model.viewmodel.AccountViewModel; +import org.joinmastodon.android.ui.viewholders.AccountViewHolder; + +import java.util.List; +import java.util.stream.Collectors; + +import me.grishka.appkit.api.SimpleCallback; +import me.grishka.appkit.utils.V; + +@SuppressLint("ValidFragment") // This shouldn't be part of any saved states anyway +public class AddNewListMembersFragment extends AccountSearchFragment{ + private Listener listener; + private String maxID; + + public AddNewListMembersFragment(Listener listener){ + this.listener=listener; + } + + @Override + public void onCreate(Bundle savedInstanceState){ + super.onCreate(savedInstanceState); + loadData(); + } + + @Override + protected void doLoadData(int offset, int count){ + if(TextUtils.isEmpty(currentQuery)){ + currentRequest=new GetAccountFollowing(AccountSessionManager.get(accountID).self.id, offset>0 ? maxID : null, count) + .setCallback(new SimpleCallback<>(this){ + @Override + public void onSuccess(HeaderPaginationList result){ + setEmptyText(""); + onDataLoaded(result.stream().map(a->new AccountViewModel(a, accountID)).collect(Collectors.toList()), result.nextPageUri!=null); + maxID=result.getNextPageMaxID(); + } + }) + .exec(accountID); + }else{ + refreshing=true; + currentRequest=new SearchAccounts(currentQuery, 0, 0, false, true) + .setCallback(new SimpleCallback<>(this){ + @Override + public void onSuccess(List result){ + AddNewListMembersFragment.this.onSuccess(result); + } + }) + .exec(accountID); + } + } + + @Override + protected String getSearchViewPlaceholder(){ + return getString(R.string.search_among_people_you_follow); + } + + @Override + protected void onConfigureViewHolder(AccountViewHolder holder){ + holder.setStyle(AccountViewHolder.AccessoryType.CUSTOM_BUTTON, false); + holder.setOnLongClickListener(vh->false); + Button button=holder.getButton(); + button.setPadding(V.dp(24), 0, V.dp(24), 0); + button.setMinimumWidth(0); + button.setMinWidth(0); + button.setOnClickListener(v->{ + holder.setActionProgressVisible(true); + holder.itemView.setHasTransientState(true); + Runnable onDone=()->{ + holder.setActionProgressVisible(false); + holder.itemView.setHasTransientState(false); + onBindViewHolder(holder); + }; + AccountViewModel account=holder.getItem(); + if(listener.isAccountInList(account)){ + listener.removeAccountAccountFromList(account, onDone); + }else{ + listener.addAccountToList(account, onDone); + } + }); + } + + @Override + protected void onBindViewHolder(AccountViewHolder holder){ + Button button=holder.getButton(); + int textRes, styleRes; + if(listener.isAccountInList(holder.getItem())){ + textRes=R.string.remove; + styleRes=R.style.Widget_Mastodon_M3_Button_Tonal_Error; + }else{ + textRes=R.string.add; + styleRes=R.style.Widget_Mastodon_M3_Button_Filled; + } + button.setText(textRes); + TypedArray ta=button.getContext().obtainStyledAttributes(styleRes, new int[]{android.R.attr.background}); + button.setBackground(ta.getDrawable(0)); + ta.recycle(); + ta=button.getContext().obtainStyledAttributes(styleRes, new int[]{android.R.attr.textColor}); + button.setTextColor(ta.getColorStateList(0)); + ta.recycle(); + } + + @Override + protected void loadRelationships(List accounts){ + // no-op + } + + public interface Listener{ + boolean isAccountInList(AccountViewModel account); + void addAccountToList(AccountViewModel account, Runnable onDone); + void removeAccountAccountFromList(AccountViewModel account, Runnable onDone); + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/account_list/BaseAccountListFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/account_list/BaseAccountListFragment.java index ca95f4697..f758bb927 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/account_list/BaseAccountListFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/account_list/BaseAccountListFragment.java @@ -39,6 +39,7 @@ public abstract class BaseAccountListFragment extends MastodonRecyclerFragment relationships=new HashMap<>(); protected String accountID; protected ArrayList> relationshipsRequests=new ArrayList<>(); + protected int itemLayoutRes=R.layout.item_account_list; public BaseAccountListFragment(){ super(40); @@ -74,6 +75,8 @@ public abstract class BaseAccountListFragment extends MastodonRecyclerFragment accounts){ Set ids=accounts.stream().map(ai->ai.account.id).collect(Collectors.toSet()); + if(ids.isEmpty()) + return; GetAccountRelationships req=new GetAccountRelationships(ids); relationshipsRequests.add(req); req.setCallback(new Callback<>(){ @@ -124,13 +127,6 @@ public abstract class BaseAccountListFragment extends MastodonRecyclerFragment implements ImageLoaderRecyclerAdapter{ public AccountsAdapter(){ @@ -171,7 +168,7 @@ public abstract class BaseAccountListFragment extends MastodonRecyclerFragment extends BaseAccountListFra } remoteDisabled = !GlobalUserPreferences.allowRemoteLoading - || getSession().domain.equals(getRemoteDomain()); + || getSession().domain.equals(getRemoteDomain()) + || remoteInfoRequest == null; if (!remoteDisabled) { remoteInfoRequest = loadRemoteInfo().setCallback(new Callback<>() { @Override diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/discover/DiscoverPostsFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/discover/DiscoverPostsFragment.java index fb25819ed..28f34be35 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/discover/DiscoverPostsFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/discover/DiscoverPostsFragment.java @@ -18,7 +18,7 @@ import me.grishka.appkit.utils.MergeRecyclerAdapter; public class DiscoverPostsFragment extends StatusListFragment{ private DiscoverInfoBannerHelper bannerHelper; - private int offset; + private int realOffset=0; @Override public void onCreate(Bundle savedInstanceState){ @@ -26,26 +26,23 @@ public class DiscoverPostsFragment extends StatusListFragment{ bannerHelper=new DiscoverInfoBannerHelper(DiscoverInfoBannerHelper.BannerType.TRENDING_POSTS, accountID); } - @Override - protected void doLoadData(int o, int count){ - if(refreshing) offset=0; - currentRequest=new GetTrendingStatuses(offset, count) + protected void doLoadData(int offset, int count){ + currentRequest=new GetTrendingStatuses(offset==0 ? 0 : realOffset, count) .setCallback(new SimpleCallback<>(this){ @Override public void onSuccess(List result){ if(getActivity()==null) return; - boolean empty=result.isEmpty(); - offset+=result.size(); + realOffset+=result.size(); AccountSessionManager.get(accountID).filterStatuses(result, getFilterContext()); - onDataLoaded(result, !empty); + onDataLoaded(result, !result.isEmpty()); bannerHelper.onBannerBecameVisible(); } }).exec(accountID); } @Override - protected RecyclerView.Adapter getAdapter(){ + protected RecyclerView.Adapter getAdapter(){ MergeRecyclerAdapter adapter=new MergeRecyclerAdapter(); bannerHelper.maybeAddBanner(list, adapter); adapter.addAdapter(super.getAdapter()); diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/discover/FederatedTimelineFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/discover/FederatedTimelineFragment.java index 5ff063738..eb74bd278 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/discover/FederatedTimelineFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/discover/FederatedTimelineFragment.java @@ -29,7 +29,7 @@ public class FederatedTimelineFragment extends StatusListFragment{ @Override protected void doLoadData(int offset, int count){ - currentRequest=new GetPublicTimeline(false, false, getMaxID(), count, getLocalPrefs().timelineReplyVisibility) + currentRequest=new GetPublicTimeline(false, false, getMaxID(), null, count, null, getLocalPrefs().timelineReplyVisibility) .setCallback(new SimpleCallback<>(this){ @Override public void onSuccess(List result){ diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/discover/LocalTimelineFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/discover/LocalTimelineFragment.java index aae59908c..d6975f9af 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/discover/LocalTimelineFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/discover/LocalTimelineFragment.java @@ -29,7 +29,7 @@ public class LocalTimelineFragment extends StatusListFragment{ @Override protected void doLoadData(int offset, int count){ - currentRequest=new GetPublicTimeline(true, false, getMaxID(), count, getLocalPrefs().timelineReplyVisibility) + currentRequest=new GetPublicTimeline(true, false, getMaxID(), null, count, null, getLocalPrefs().timelineReplyVisibility) .setCallback(new SimpleCallback<>(this){ @Override public void onSuccess(List result){ diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/discover/SearchFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/discover/SearchFragment.java index 599d82077..f5c605a94 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/discover/SearchFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/discover/SearchFragment.java @@ -143,7 +143,7 @@ public class SearchFragment extends BaseStatusListFragment{ }*/ int offset=_offset; currentRequest=new GetSearchResults(currentQuery, type, type==null, maxID, offset, type==null ? 0 : count) - .setCallback(new SimpleCallback<>(this){ + .setCallback(new SimpleCallback(this){ @Override public void onSuccess(SearchResults result){ ArrayList results=new ArrayList<>(); @@ -164,7 +164,10 @@ public class SearchFragment extends BaseStatusListFragment{ } prevDisplayItems=new ArrayList<>(displayItems); unfilteredResults=results; + boolean wasRefreshing=refreshing; onDataLoaded(filterSearchResults(results), type!=null && !results.isEmpty()); + if(wasRefreshing) + list.scrollToPosition(0); } }) .setTimeout(180000) // 3 minutes (searches can take a long time) diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/onboarding/AccountActivationFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/onboarding/AccountActivationFragment.java index 75325ad4a..c3d36b9f2 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/onboarding/AccountActivationFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/onboarding/AccountActivationFragment.java @@ -24,7 +24,7 @@ import org.joinmastodon.android.api.session.AccountSession; import org.joinmastodon.android.api.session.AccountSessionManager; import org.joinmastodon.android.fragments.settings.SettingsMainFragment; import org.joinmastodon.android.model.Account; -import org.joinmastodon.android.ui.AccountSwitcherSheet; +import org.joinmastodon.android.ui.sheets.AccountSwitcherSheet; import org.joinmastodon.android.ui.utils.UiUtils; import java.io.File; diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/onboarding/GoogleMadeMeAddThisFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/onboarding/GoogleMadeMeAddThisFragment.java index 05746a5a4..03d0c3557 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/onboarding/GoogleMadeMeAddThisFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/onboarding/GoogleMadeMeAddThisFragment.java @@ -137,6 +137,9 @@ public class GoogleMadeMeAddThisFragment extends ToolbarFragment{ protected void onButtonClick(){ Bundle args=new Bundle(); args.putParcelable("instance", Parcels.wrap(instance)); + if(getArguments().containsKey("inviteCode")){ + args.putString("inviteCode", getArguments().getString("inviteCode")); + } Nav.goForResult(getActivity(), SignupFragment.class, args, SIGNUP_REQUEST, this); } diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/onboarding/InstanceCatalogFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/onboarding/InstanceCatalogFragment.java index ed9de50e4..0980f6673 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/onboarding/InstanceCatalogFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/onboarding/InstanceCatalogFragment.java @@ -3,7 +3,6 @@ package org.joinmastodon.android.fragments.onboarding; import android.app.Activity; import android.app.ProgressDialog; import android.net.Uri; -import android.os.Build; import android.os.Bundle; import android.text.TextUtils; import android.view.KeyEvent; @@ -38,6 +37,7 @@ import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Objects; +import java.util.function.Consumer; import java.util.stream.Collectors; import javax.xml.parsers.DocumentBuilderFactory; @@ -48,7 +48,6 @@ import me.grishka.appkit.api.Callback; import me.grishka.appkit.api.ErrorResponse; import me.grishka.appkit.utils.BindableViewHolder; import me.grishka.appkit.utils.MergeRecyclerAdapter; -import me.grishka.appkit.utils.V; import okhttp3.Call; import okhttp3.Request; import okhttp3.Response; @@ -61,6 +60,7 @@ abstract class InstanceCatalogFragment extends MastodonRecyclerFragment instancesCache=new HashMap<>(); protected View buttonBar; @@ -91,6 +91,7 @@ abstract class InstanceCatalogFragment extends MastodonRecyclerFragment onError){ if(TextUtils.isEmpty(_domain)) return; String domain=normalizeInstanceDomain(_domain); @@ -180,7 +186,10 @@ abstract class InstanceCatalogFragment extends MastodonRecyclerFragment0 && filteredData.get(0)==fakeInstance){ @@ -200,10 +209,11 @@ abstract class InstanceCatalogFragment extends MastodonRecyclerFragment0 && filteredData.get(0)==fakeInstance){ @@ -283,7 +296,7 @@ abstract class InstanceCatalogFragment extends MastodonRecyclerFragment onError){ String url="https://"+domain+"/.well-known/host-meta"; Request req=new Request.Builder() .url(url) @@ -297,7 +310,12 @@ abstract class InstanceCatalogFragment extends MastodonRecyclerFragmentshowInstanceInfoLoadError(domain, e)); + a.runOnUiThread(()->{ + if(onError!=null) + onError.accept(e); + else + showInstanceInfoLoadError(domain, e); + }); } @Override @@ -309,7 +327,13 @@ abstract class InstanceCatalogFragment extends MastodonRecyclerFragmentshowInstanceInfoLoadError(domain, response.code()+" "+response.message())); + a.runOnUiThread(()->{ + String err=response.code()+" "+response.message(); + if(onError!=null) + onError.accept(err); + else + showInstanceInfoLoadError(domain, err); + }); return; } InputSource source=new InputSource(response.body().charStream()); @@ -328,9 +352,19 @@ abstract class InstanceCatalogFragment extends MastodonRecyclerFragmentshowInstanceInfoLoadError(domain, origError)); + a.runOnUiThread(()->{ + if(onError!=null) + onError.accept(origError); + else + showInstanceInfoLoadError(domain, origError); + }); }catch(Exception x){ - a.runOnUiThread(()->showInstanceInfoLoadError(domain, x)); + a.runOnUiThread(()->{ + if(onError!=null) + onError.accept(x); + else + showInstanceInfoLoadError(domain, x); + }); } } }); diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/onboarding/InstanceCatalogSignupFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/onboarding/InstanceCatalogSignupFragment.java index 798b18ac5..68decec3e 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/onboarding/InstanceCatalogSignupFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/onboarding/InstanceCatalogSignupFragment.java @@ -1,8 +1,13 @@ package org.joinmastodon.android.fragments.onboarding; import android.annotation.SuppressLint; +import android.app.AlertDialog; +import android.content.ClipData; +import android.content.ClipboardManager; import android.content.Context; +import android.content.DialogInterface; import android.content.res.ColorStateList; +import android.net.Uri; import android.os.Bundle; import android.text.Editable; import android.text.TextUtils; @@ -12,6 +17,8 @@ import android.view.View; import android.view.ViewGroup; import android.view.WindowInsets; import android.view.inputmethod.InputMethodManager; +import android.widget.Button; +import android.widget.EditText; import android.widget.HorizontalScrollView; import android.widget.ImageButton; import android.widget.LinearLayout; @@ -19,9 +26,12 @@ import android.widget.PopupMenu; import android.widget.RadioButton; import android.widget.RelativeLayout; import android.widget.TextView; +import android.widget.Toast; import org.joinmastodon.android.R; import org.joinmastodon.android.api.MastodonAPIRequest; +import org.joinmastodon.android.api.MastodonErrorResponse; +import org.joinmastodon.android.api.requests.accounts.CheckInviteLink; import org.joinmastodon.android.api.requests.catalog.GetCatalogCategories; import org.joinmastodon.android.api.requests.catalog.GetCatalogInstances; import org.joinmastodon.android.model.Instance; @@ -29,6 +39,8 @@ import org.joinmastodon.android.model.catalog.CatalogCategory; import org.joinmastodon.android.model.catalog.CatalogInstance; import org.joinmastodon.android.ui.BetterItemAnimator; import org.joinmastodon.android.ui.M3AlertDialogBuilder; +import org.joinmastodon.android.ui.text.HtmlParser; +import org.joinmastodon.android.ui.utils.SimpleTextWatcher; import org.joinmastodon.android.ui.utils.UiUtils; import org.joinmastodon.android.ui.views.FilterChipView; import org.joinmastodon.android.utils.ElevationOnScrollListener; @@ -40,7 +52,9 @@ import java.util.Collections; import java.util.Comparator; import java.util.List; import java.util.Locale; +import java.util.Objects; import java.util.Random; +import java.util.function.Consumer; import java.util.stream.Collectors; import androidx.annotation.NonNull; @@ -77,6 +91,9 @@ public class InstanceCatalogSignupFragment extends InstanceCatalogFragment imple private CatalogInstance.Region chosenRegion; private CategoryChoice categoryChoice=CategoryChoice.GENERAL; + private String inviteCode, inviteCodeHost; + private AlertDialog currentInviteLinkAlert; + public InstanceCatalogSignupFragment(){ super(R.layout.fragment_onboarding_common, 10); } @@ -317,7 +334,7 @@ public class InstanceCatalogSignupFragment extends InstanceCatalogFragment imple focusThing=view.findViewById(R.id.focus_thing); focusThing.requestFocus(); - view.findViewById(R.id.btn_random_instance).setOnClickListener(this::onPickRandomInstanceClick); + view.findViewById(R.id.btn_use_invite).setOnClickListener(this::onUseInviteClick); nextButton.setEnabled(chosenInstance!=null); } @@ -351,91 +368,191 @@ public class InstanceCatalogSignupFragment extends InstanceCatalogFragment imple @Override protected void proceedWithAuthOrSignup(Instance instance){ + if(currentInviteLinkAlert!=null){ + currentInviteLinkAlert.dismiss(); + }else if(!TextUtils.isEmpty(currentSearchQuery) && HtmlParser.INVITE_LINK_PATTERN.matcher(currentSearchQueryButWithCasePreserved).find()){ + if(TextUtils.isEmpty(inviteCode) || !Objects.equals(instance.uri, inviteCodeHost)){ + Uri inviteLink=Uri.parse(currentSearchQueryButWithCasePreserved); + new CheckInviteLink(inviteLink.getPath()) + .setCallback(new Callback<>(){ + @Override + public void onSuccess(CheckInviteLink.Response result){ + inviteCodeHost=inviteLink.getHost(); + inviteCode=result.inviteCode; + proceedWithAuthOrSignup(instance); + } + + @Override + public void onError(ErrorResponse error){ + if(getActivity()==null) + return; + if(error instanceof MastodonErrorResponse mer){ + switch(mer.httpStatus){ + case 401 -> new M3AlertDialogBuilder(getActivity()) + .setTitle(R.string.expired_invite_link) + .setMessage(getString(R.string.expired_clipboard_invite_link_alert, inviteLink.getHost(), getArguments().getString("defaultServer"))) + .setPositiveButton(R.string.ok, null) + .show(); + case 404 -> new M3AlertDialogBuilder(getActivity()) + .setTitle(R.string.invalid_invite_link) + .setMessage(getString(R.string.invalid_clipboard_invite_link_alert, inviteLink.getHost(), getArguments().getString("defaultServer"))) + .setPositiveButton(R.string.ok, null) + .show(); + default -> error.showToast(getActivity()); + } + } + } + }) + .wrapProgress(getActivity(), R.string.loading_instance, true) + .execNoAuth(inviteLink.getHost()); + return; + } + } getActivity().getSystemService(InputMethodManager.class).hideSoftInputFromWindow(contentView.getWindowToken(), 0); - if(!instance.registrations){ - new M3AlertDialogBuilder(getActivity()) - .setTitle(R.string.error) - .setMessage(R.string.instance_signup_closed) - .setPositiveButton(R.string.ok, null) - .show(); + if(!instance.registrations && (TextUtils.isEmpty(inviteCode) || !Objects.equals(instance.uri, inviteCodeHost))){ + if(instance.invitesEnabled){ + showInviteLinkAlert(instance.uri); + }else{ + new M3AlertDialogBuilder(getActivity()) + .setTitle(R.string.error) + .setMessage(R.string.instance_signup_closed) + .setPositiveButton(R.string.ok, null) + .show(); + } return; } Bundle args=new Bundle(); args.putParcelable("instance", Parcels.wrap(instance)); + if(!TextUtils.isEmpty(inviteCode) && Objects.equals(instance.uri, inviteCodeHost)) + args.putString("inviteCode", inviteCode); Nav.go(getActivity(), InstanceRulesFragment.class, args); } - private void onPickRandomInstanceClick(View v){ - String lang=Locale.getDefault().getLanguage(); - List instances=data.stream().filter(ci->!ci.approvalRequired && ("general".equals(ci.category) || (ci.categories!=null && ci.categories.contains("general"))) && (lang.equals(ci.language) || (ci.languages!=null && ci.languages.contains(lang)))).collect(Collectors.toList()); - if(instances.isEmpty()){ - instances=data.stream().filter(ci->!ci.approvalRequired && ("general".equals(ci.category) || (ci.categories!=null && ci.categories.contains("general")))).collect(Collectors.toList()); - } - if(instances.isEmpty()){ - instances=data.stream().filter(ci->("general".equals(ci.category) || (ci.categories!=null && ci.categories.contains("general")))).collect(Collectors.toList()); - } - if(instances.isEmpty()){ - return; - } - chosenInstance=instances.get(new Random().nextInt(instances.size())); - onNextClick(v); + private void onUseInviteClick(View v){ + showInviteLinkAlert(null); } -// private String getEmojiForCategory(String category){ -// return switch(category){ -// case "all" -> "💬"; -// case "academia" -> "📚"; -// case "activism" -> "✊"; -// case "food" -> "🍕"; -// case "furry" -> "🦁"; -// case "games" -> "🕹"; -// case "general" -> "🐘"; -// case "journalism" -> "📰"; -// case "lgbt" -> "🏳️‍🌈"; -// case "regional" -> "📍"; -// case "art" -> "🎨"; -// case "music" -> "🎼"; -// case "tech" -> "📱"; -// default -> "❓"; -// }; -// } + private void showInviteLinkAlert(String domain){ + AlertDialog alert=new M3AlertDialogBuilder(getActivity()) + .setView(R.layout.alert_invite_link) + .setPositiveButton(R.string.next, null) + .setNegativeButton(R.string.cancel, null) + .create(); - private int getEmojiForCategory(String category){ - return switch(category){ - case "all" -> R.drawable.ic_category_all; - case "academia" -> R.drawable.ic_category_academia; - case "activism" -> R.drawable.ic_category_activism; - case "food" -> R.drawable.ic_category_food; - case "furry" -> R.drawable.ic_category_furry; - case "games" -> R.drawable.ic_category_games; - case "general" -> R.drawable.ic_category_general; - case "journalism" -> R.drawable.ic_category_journalism; - case "lgbt" -> R.drawable.ic_category_lgbt; - case "regional" -> R.drawable.ic_category_regional; - case "art" -> R.drawable.ic_category_art; - case "music" -> R.drawable.ic_category_music; - case "tech" -> R.drawable.ic_category_tech; - default -> R.drawable.ic_category_unknown; - }; - } + Button next=alert.getButton(AlertDialog.BUTTON_POSITIVE); + EditText edit=alert.findViewById(R.id.edit); + TextView supportingText=alert.findViewById(R.id.supporting_text); + TextView label=alert.findViewById(R.id.label); + TextView subtitle=alert.findViewById(R.id.subtitle); + ImageButton clear=alert.findViewById(R.id.clear); + clear.setVisibility(View.GONE); - private int getTitleForCategory(String category){ - return switch(category){ - case "all" -> R.string.category_all; - case "academia" -> R.string.category_academia; - case "activism" -> R.string.category_activism; - case "food" -> R.string.category_food; - case "furry" -> R.string.category_furry; - case "games" -> R.string.category_games; - case "general" -> R.string.category_general; - case "journalism" -> R.string.category_journalism; - case "lgbt" -> R.string.category_lgbt; - case "regional" -> R.string.category_regional; - case "art" -> R.string.category_art; - case "music" -> R.string.category_music; - case "tech" -> R.string.category_tech; - default -> 0; + if(TextUtils.isEmpty(domain)){ + subtitle.setVisibility(View.GONE); + }else{ + subtitle.setText(getString(R.string.need_invite_to_join_server, domain)); + } + + Consumer errorSetter=err->{ + supportingText.setText(err); + int errorColor=UiUtils.getThemeColor(getActivity(), R.attr.colorM3Error); + supportingText.setTextColor(errorColor); + label.setTextColor(errorColor); + edit.setBackgroundResource(R.drawable.bg_m3_filled_text_field_error); }; + + next.setOnClickListener(_v->{ + Uri inviteLink=Uri.parse(edit.getText().toString()); + if(TextUtils.isEmpty(inviteLink.getHost()) || TextUtils.isEmpty(inviteLink.getPath())){ + errorSetter.accept(getString(R.string.this_invite_is_invalid)); + return; + } + UiUtils.showProgressForAlertButton(next, true); + new CheckInviteLink(inviteLink.getPath()) + .setCallback(new Callback<>(){ + @Override + public void onSuccess(CheckInviteLink.Response result){ + if(getActivity()==null || !alert.isShowing()) + return; + + String host=inviteLink.getHost(); + inviteCode=result.inviteCode; + inviteCodeHost=host; + + Instance instance=instancesCache.get(normalizeInstanceDomain(host)); + if(instance==null){ + loadInstanceInfo(host, false, err->{ + String errorStr; + if(err instanceof String str){ + errorStr=str; + }else if(err instanceof Throwable x){ + errorStr=x.getMessage(); + }else if(err instanceof MastodonErrorResponse mer){ + errorStr=mer.error; + }else{ + errorStr=getString(R.string.error); + } + errorSetter.accept(errorStr); + UiUtils.showProgressForAlertButton(next, false); + }); + }else{ + proceedWithAuthOrSignup(instance); + } + } + + @Override + public void onError(ErrorResponse error){ + if(getActivity()==null || !alert.isShowing()) + return; + UiUtils.showProgressForAlertButton(next, false); + if(error instanceof MastodonErrorResponse mer){ + errorSetter.accept(switch(mer.httpStatus){ + case 404 -> getString(R.string.this_invite_is_invalid); + case 401 -> getString(R.string.this_invite_has_expired); + default -> mer.error; + }); + } + } + }) + .execNoAuth(inviteLink.getHost()); + }); + next.setEnabled(false); + edit.addTextChangedListener(new SimpleTextWatcher(e->{ + boolean wasEmpty=!next.isEnabled(); + next.setEnabled(e.length()>0); + if(supportingText.length()>0){ + supportingText.setText(""); + int regularColor=UiUtils.getThemeColor(getActivity(), R.attr.colorM3OnSurfaceVariant); + supportingText.setTextColor(regularColor); + label.setTextColor(regularColor); + edit.setBackgroundResource(R.drawable.bg_m3_filled_text_field); + } + if(wasEmpty!=(e.length()==0)){ + int padEnd; + if(e.length()==0){ + clear.setVisibility(View.GONE); + padEnd=V.dp(16); + }else{ + clear.setVisibility(View.VISIBLE); + padEnd=V.dp(48); + } + edit.setPaddingRelative(edit.getPaddingStart(), edit.getPaddingTop(), padEnd, edit.getPaddingBottom()); + } + })); + clear.setOnClickListener(_v->edit.setText("")); + + ClipData clipData=getActivity().getSystemService(ClipboardManager.class).getPrimaryClip(); + if(clipData!=null && clipData.getItemCount()>0){ + CharSequence clipText=clipData.getItemAt(0).coerceToText(getActivity()); + if(HtmlParser.INVITE_LINK_PATTERN.matcher(clipText).find()){ + edit.setText(clipText); + supportingText.setText(R.string.invite_link_pasted); + } + } + + currentInviteLinkAlert=alert; + alert.setOnDismissListener(dialog->currentInviteLinkAlert=null); + alert.show(); } @Override @@ -444,8 +561,14 @@ public class InstanceCatalogSignupFragment extends InstanceCatalogFragment imple filteredData.clear(); if(searchQueryMode){ if(!TextUtils.isEmpty(currentSearchQuery)){ + String actualQuery; + if(currentSearchQuery.startsWith("https:")){ + actualQuery=Uri.parse(currentSearchQuery).getHost(); + }else{ + actualQuery=currentSearchQuery; + } for(CatalogInstance instance:data){ - if(instance.domain.contains(currentSearchQuery)){ + if(instance.domain.contains(actualQuery)){ filteredData.add(instance); } } diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/onboarding/InstanceRulesFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/onboarding/InstanceRulesFragment.java index bbf7eac0c..7432db287 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/onboarding/InstanceRulesFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/onboarding/InstanceRulesFragment.java @@ -111,6 +111,9 @@ public class InstanceRulesFragment extends ToolbarFragment implements ProvidesAs protected void onButtonClick(){ Bundle args=new Bundle(); args.putParcelable("instance", Parcels.wrap(instance)); + if(getArguments().containsKey("inviteCode")){ + args.putString("inviteCode", getArguments().getString("inviteCode")); + } Nav.goForResult(getActivity(), GoogleMadeMeAddThisFragment.class, args, RULES_REQUEST, this); } diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/onboarding/OnboardingFollowSuggestionsFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/onboarding/OnboardingFollowSuggestionsFragment.java index a59d97749..326e54db6 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/onboarding/OnboardingFollowSuggestionsFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/onboarding/OnboardingFollowSuggestionsFragment.java @@ -5,6 +5,7 @@ import android.net.Uri; import android.os.Bundle; import android.view.View; import android.view.WindowInsets; +import android.widget.TextView; import org.joinmastodon.android.R; import org.joinmastodon.android.api.requests.accounts.GetFollowSuggestions; @@ -13,35 +14,38 @@ import org.joinmastodon.android.fragments.account_list.BaseAccountListFragment; import org.joinmastodon.android.model.FollowSuggestion; import org.joinmastodon.android.model.Relationship; import org.joinmastodon.android.model.viewmodel.AccountViewModel; +import org.joinmastodon.android.ui.OutlineProviders; import org.joinmastodon.android.ui.utils.UiUtils; import org.joinmastodon.android.ui.viewholders.AccountViewHolder; -import org.joinmastodon.android.utils.ElevationOnScrollListener; import java.util.ArrayList; import java.util.List; import java.util.stream.Collectors; +import androidx.recyclerview.widget.RecyclerView; import me.grishka.appkit.Nav; import me.grishka.appkit.api.Callback; import me.grishka.appkit.api.ErrorResponse; import me.grishka.appkit.api.SimpleCallback; -import me.grishka.appkit.views.FragmentRootLinearLayout; +import me.grishka.appkit.utils.MergeRecyclerAdapter; +import me.grishka.appkit.utils.SingleViewRecyclerAdapter; +import me.grishka.appkit.utils.V; public class OnboardingFollowSuggestionsFragment extends BaseAccountListFragment{ private String accountID; private View buttonBar; - private ElevationOnScrollListener onScrollListener; private int numRunningFollowRequests=0; public OnboardingFollowSuggestionsFragment(){ super(R.layout.fragment_onboarding_follow_suggestions, 40); + itemLayoutRes=R.layout.item_account_list; } @Override public void onCreate(Bundle savedInstanceState){ super.onCreate(savedInstanceState); setRetainInstance(true); - setTitle(R.string.popular_on_mastodon); + setTitle(R.string.onboarding_recommendations_title); accountID=getArguments().getString("account"); loadData(); } @@ -50,7 +54,6 @@ public class OnboardingFollowSuggestionsFragment extends BaseAccountListFragment public void onViewCreated(View view, Bundle savedInstanceState){ super.onViewCreated(view, savedInstanceState); buttonBar=view.findViewById(R.id.button_bar); - list.addOnScrollListener(onScrollListener=new ElevationOnScrollListener((FragmentRootLinearLayout) view, buttonBar, getToolbar())); view.findViewById(R.id.btn_next).setOnClickListener(UiUtils.rateLimitedClickListener(this::onFollowAllClick)); // view.findViewById(R.id.btn_skip).setOnClickListener(UiUtils.rateLimitedClickListener(v->proceed())); @@ -59,9 +62,7 @@ public class OnboardingFollowSuggestionsFragment extends BaseAccountListFragment @Override protected void onUpdateToolbar(){ super.onUpdateToolbar(); - if(onScrollListener!=null){ - onScrollListener.setViews(buttonBar, getToolbar()); - } + getToolbar().setContentInsetsRelative(V.dp(56), 0); } @Override @@ -70,7 +71,7 @@ public class OnboardingFollowSuggestionsFragment extends BaseAccountListFragment .setCallback(new SimpleCallback<>(this){ @Override public void onSuccess(List result){ - onDataLoaded(result.stream().map(fs->new AccountViewModel(fs.account, accountID)).collect(Collectors.toList()), false); + onDataLoaded(result.stream().map(fs->new AccountViewModel(fs.account, accountID).stripLinksFromBio()).collect(Collectors.toList()), false); } }) .exec(accountID); @@ -81,6 +82,20 @@ public class OnboardingFollowSuggestionsFragment extends BaseAccountListFragment super.onApplyWindowInsets(UiUtils.applyBottomInsetToFixedView(buttonBar, insets)); } + @Override + protected RecyclerView.Adapter getAdapter(){ +// Unused in Moshidon +// TextView introText=new TextView(getActivity()); +// introText.setTextAppearance(R.style.m3_body_large); +// introText.setTextColor(UiUtils.getThemeColor(getActivity(), R.attr.colorM3OnSurface)); +// introText.setPaddingRelative(V.dp(56), 0, V.dp(24), V.dp(8)); +// introText.setText(R.string.onboarding_recommendations_intro); + MergeRecyclerAdapter mergeAdapter=new MergeRecyclerAdapter(); +// mergeAdapter.addAdapter(new SingleViewRecyclerAdapter(introText)); + mergeAdapter.addAdapter(super.getAdapter()); + return mergeAdapter; + } + private void onFollowAllClick(View v){ if(!loaded || relationships.isEmpty()) return; @@ -156,6 +171,12 @@ public class OnboardingFollowSuggestionsFragment extends BaseAccountListFragment protected void onConfigureViewHolder(AccountViewHolder holder){ super.onConfigureViewHolder(holder); holder.setStyle(AccountViewHolder.AccessoryType.BUTTON, true); + holder.avatar.setOutlineProvider(OutlineProviders.roundedRect(8)); + } + + @Override + public Uri getWebUri(Uri.Builder base){ + return null; } @Override diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/onboarding/OnboardingProfileSetupFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/onboarding/OnboardingProfileSetupFragment.java index 086bef065..892261e4d 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/onboarding/OnboardingProfileSetupFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/onboarding/OnboardingProfileSetupFragment.java @@ -11,6 +11,7 @@ import android.view.WindowInsets; import android.widget.Button; import android.widget.EditText; import android.widget.ImageView; +import android.widget.LinearLayout; import android.widget.ScrollView; import org.joinmastodon.android.R; @@ -19,12 +20,17 @@ import org.joinmastodon.android.api.session.AccountSessionManager; import org.joinmastodon.android.fragments.HomeFragment; import org.joinmastodon.android.model.Account; import org.joinmastodon.android.model.AccountField; +import org.joinmastodon.android.model.viewmodel.CheckableListItem; +import org.joinmastodon.android.ui.M3AlertDialogBuilder; import org.joinmastodon.android.ui.OutlineProviders; +import org.joinmastodon.android.ui.adapters.GenericListItemsAdapter; import org.joinmastodon.android.ui.utils.UiUtils; +import org.joinmastodon.android.ui.viewholders.ListItemViewHolder; import org.joinmastodon.android.ui.views.ReorderableLinearLayout; import org.joinmastodon.android.utils.ElevationOnScrollListener; import java.util.ArrayList; +import java.util.List; import me.grishka.appkit.Nav; import me.grishka.appkit.api.Callback; @@ -35,7 +41,7 @@ import me.grishka.appkit.imageloader.requests.UrlImageLoaderRequest; import me.grishka.appkit.utils.V; import me.grishka.appkit.views.FragmentRootLinearLayout; -public class OnboardingProfileSetupFragment extends ToolbarFragment implements ReorderableLinearLayout.OnDragListener{ +public class OnboardingProfileSetupFragment extends ToolbarFragment{ private Button btn; private View buttonBar; private String accountID; @@ -43,9 +49,9 @@ public class OnboardingProfileSetupFragment extends ToolbarFragment implements R private ScrollView scroller; private EditText nameEdit, bioEdit; private ImageView avaImage, coverImage; - private Button addRow; - private ReorderableLinearLayout profileFieldsLayout; private Uri avatarUri, coverUri; + private LinearLayout scrollContent; + private CheckableListItem discoverableItem; private static final int AVATAR_RESULT=348; private static final int COVER_RESULT=183; @@ -73,8 +79,6 @@ public class OnboardingProfileSetupFragment extends ToolbarFragment implements R bioEdit=view.findViewById(R.id.bio); avaImage=view.findViewById(R.id.avatar); coverImage=view.findViewById(R.id.header); - addRow=view.findViewById(R.id.add_row); - profileFieldsLayout=view.findViewById(R.id.profile_fields); btn=view.findViewById(R.id.btn_next); btn.setOnClickListener(v->onButtonClick()); @@ -86,31 +90,20 @@ public class OnboardingProfileSetupFragment extends ToolbarFragment implements R Account account=AccountSessionManager.getInstance().getAccount(accountID).self; if(savedInstanceState==null){ nameEdit.setText(account.displayName); - makeFieldsRow(); - }else{ - ArrayList fieldTitles=savedInstanceState.getStringArrayList("fieldTitles"); - ArrayList fieldValues=savedInstanceState.getStringArrayList("fieldValues"); - for(int i=0;i{ - makeFieldsRow(); - if(profileFieldsLayout.getChildCount()==4){ - addRow.setVisibility(View.GONE); - } - }); - profileFieldsLayout.setDragListener(this); avaImage.setOnClickListener(v->startActivityForResult(UiUtils.getMediaPickerIntent(new String[]{"image/*"}, 1), AVATAR_RESULT)); coverImage.setOnClickListener(v->startActivityForResult(UiUtils.getMediaPickerIntent(new String[]{"image/*"}, 1), COVER_RESULT)); + scrollContent=view.findViewById(R.id.scrollable_content); + discoverableItem=new CheckableListItem<>(R.string.make_profile_discoverable, 0, CheckableListItem.Style.SWITCH_SEPARATED, true, R.drawable.ic_campaign_24px, item->showDiscoverabilityAlert()); + GenericListItemsAdapter fakeAdapter=new GenericListItemsAdapter<>(List.of(discoverableItem)); + ListItemViewHolder holder=fakeAdapter.onCreateViewHolder(scrollContent, fakeAdapter.getItemViewType(0)); + fakeAdapter.bindViewHolder(holder, 0); + holder.itemView.setBackground(UiUtils.getThemeDrawable(getActivity(), android.R.attr.selectableItemBackground)); + holder.itemView.setOnClickListener(v->holder.onClick()); + scrollContent.addView(holder.itemView); + return view; } @@ -129,17 +122,8 @@ public class OnboardingProfileSetupFragment extends ToolbarFragment implements R } protected void onButtonClick(){ - ArrayList fields=new ArrayList<>(); - for(int i=0;i(){ @Override public void onSuccess(Account result){ @@ -163,39 +147,6 @@ public class OnboardingProfileSetupFragment extends ToolbarFragment implements R super.onApplyWindowInsets(UiUtils.applyBottomInsetToFixedView(buttonBar, insets)); } - private View makeFieldsRow(){ - View view=LayoutInflater.from(getActivity()).inflate(R.layout.onboarding_profile_field, profileFieldsLayout, false); - profileFieldsLayout.addView(view); - view.findViewById(R.id.dragger_thingy).setOnLongClickListener(v->{ - profileFieldsLayout.startDragging(view); - return true; - }); - view.findViewById(R.id.delete).setOnClickListener(v->{ - profileFieldsLayout.removeView(view); - if(addRow.getVisibility()==View.GONE) - addRow.setVisibility(View.VISIBLE); - }); - return view; - } - - @Override - public void onSwapItems(int oldIndex, int newIndex){} - - @Override - public void onSaveInstanceState(Bundle outState){ - super.onSaveInstanceState(outState); - ArrayList fieldTitles=new ArrayList<>(), fieldValues=new ArrayList<>(); - for(int i=0;i errorFields=new HashSet<>(); private ElevationOnScrollListener onScrollListener; + private Set serverSupportedTimezones, serverSupportedLocales; @Override public void onCreate(Bundle savedInstanceState){ @@ -87,6 +87,8 @@ public class SignupFragment extends ToolbarFragment{ instance=Parcels.unwrap(getArguments().getParcelable("instance")); createAppAndGetToken(); setTitle(R.string.signup_title); + serverSupportedTimezones=Arrays.stream(getResources().getStringArray(R.array.server_supported_timezones)).collect(Collectors.toSet()); + serverSupportedLocales=Arrays.stream(getResources().getStringArray(R.array.server_supported_locales)).collect(Collectors.toSet()); } @Nullable @@ -190,7 +192,36 @@ public class SignupFragment extends ToolbarFragment{ edit.setError(null); } errorFields.clear(); - new RegisterAccount(username, email, password.getText().toString(), getResources().getConfiguration().locale.getLanguage(), reason.getText().toString(), ZoneId.systemDefault().getId()) + String locale=null; + String timezone=ZoneId.systemDefault().getId(); + + if(Build.VERSION.SDK_INT>=Build.VERSION_CODES.N){ + LocaleList localeList=getResources().getConfiguration().getLocales(); + for(int i=0;i(){ @Override public void onSuccess(Token result){ diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/settings/BaseSettingsFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/settings/BaseSettingsFragment.java index e0ee967be..8f0643841 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/settings/BaseSettingsFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/settings/BaseSettingsFragment.java @@ -12,12 +12,10 @@ import org.joinmastodon.android.model.viewmodel.ListItem; import org.joinmastodon.android.ui.BetterItemAnimator; import org.joinmastodon.android.ui.DividerItemDecoration; import org.joinmastodon.android.ui.adapters.GenericListItemsAdapter; -import org.joinmastodon.android.ui.viewholders.CheckableListItemViewHolder; import org.joinmastodon.android.ui.viewholders.ListItemViewHolder; import org.joinmastodon.android.ui.viewholders.SimpleListItemViewHolder; import androidx.recyclerview.widget.RecyclerView; -import me.grishka.appkit.utils.V; public abstract class BaseSettingsFragment extends MastodonRecyclerFragment>{ protected GenericListItemsAdapter itemsAdapter; @@ -45,7 +43,7 @@ public abstract class BaseSettingsFragment extends MastodonRecyclerFragment getAdapter(){ - return itemsAdapter=new GenericListItemsAdapter(data); + return itemsAdapter=new GenericListItemsAdapter(imgLoader, data); } @Override @@ -59,12 +57,13 @@ public abstract class BaseSettingsFragment extends MastodonRecyclerFragment item){ - item.toggle(); + protected void toggleCheckableItem(ListItem item){ + if(item instanceof CheckableListItem checkable) + checkable.toggle(); rebindItem(item); } - protected void rebindItem(ListItem item){ + protected void rebindItem(ListItem item){ if(list==null) return; if(list.findViewHolderForAdapterPosition(indexOfItemsAdapter()+data.indexOf(item)) instanceof ListItemViewHolder holder){ diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/settings/SettingsAboutAppFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/settings/SettingsAboutAppFragment.java index acfb37cb7..6bd3a77b8 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/settings/SettingsAboutAppFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/settings/SettingsAboutAppFragment.java @@ -1,6 +1,9 @@ package org.joinmastodon.android.fragments.settings; import android.app.Activity; +import android.content.ClipData; +import android.content.ClipboardManager; +import android.os.Build; import android.os.Bundle; import android.util.Log; import android.view.Gravity; @@ -18,6 +21,7 @@ import org.joinmastodon.android.api.session.AccountSessionManager; import org.joinmastodon.android.fragments.HasAccountID; import org.joinmastodon.android.model.viewmodel.CheckableListItem; import org.joinmastodon.android.model.viewmodel.ListItem; +import org.joinmastodon.android.ui.Snackbar; import org.joinmastodon.android.ui.utils.UiUtils; import org.joinmastodon.android.updater.GithubSelfUpdater; @@ -106,6 +110,14 @@ public class SettingsAboutAppFragment extends BaseSettingsFragment impleme versionInfo.setTextColor(UiUtils.getThemeColor(getActivity(), R.attr.colorM3Outline)); versionInfo.setGravity(Gravity.CENTER); versionInfo.setText(getString(R.string.mo_settings_app_version, BuildConfig.VERSION_NAME, BuildConfig.VERSION_CODE)); + versionInfo.setOnClickListener(v->{ + getActivity().getSystemService(ClipboardManager.class).setPrimaryClip(ClipData.newPlainText("", BuildConfig.VERSION_NAME+" ("+BuildConfig.VERSION_CODE+")")); + if(Build.VERSION.SDK_INT<=Build.VERSION_CODES.S_V2){ + new Snackbar.Builder(getActivity()) + .setText(R.string.app_version_copied) + .show(); + } + }); adapter.addAdapter(new SingleViewRecyclerAdapter(versionInfo)); return adapter; diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/settings/SettingsDebugFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/settings/SettingsDebugFragment.java index bc3b59496..f67af8a72 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/settings/SettingsDebugFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/settings/SettingsDebugFragment.java @@ -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{ 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,12 @@ public class SettingsDebugFragment extends BaseSettingsFragment{ restartUI(); } + private void onResetPreReplySheetsClick(ListItem item){ + // TODO fix this +// 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); diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/settings/SettingsMainFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/settings/SettingsMainFragment.java index c126c3986..20142ebbd 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/settings/SettingsMainFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/settings/SettingsMainFragment.java @@ -1,6 +1,5 @@ package org.joinmastodon.android.fragments.settings; -import android.content.Intent; import android.os.Bundle; import android.view.View; import android.widget.Button; @@ -18,7 +17,7 @@ import org.joinmastodon.android.api.session.AccountSessionManager; import org.joinmastodon.android.events.SelfUpdateStateChangedEvent; import org.joinmastodon.android.model.Instance; import org.joinmastodon.android.model.viewmodel.ListItem; -import org.joinmastodon.android.ui.AccountSwitcherSheet; +import org.joinmastodon.android.ui.sheets.AccountSwitcherSheet; import org.joinmastodon.android.ui.M3AlertDialogBuilder; import org.joinmastodon.android.ui.utils.HideableSingleViewRecyclerAdapter; import org.joinmastodon.android.ui.utils.UiUtils; diff --git a/mastodon/src/main/java/org/joinmastodon/android/model/Card.java b/mastodon/src/main/java/org/joinmastodon/android/model/Card.java index de4554ca9..0f197ab33 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/model/Card.java +++ b/mastodon/src/main/java/org/joinmastodon/android/model/Card.java @@ -12,6 +12,7 @@ import org.joinmastodon.android.ui.utils.BlurHashDecoder; import org.joinmastodon.android.ui.utils.BlurHashDrawable; import org.parceler.Parcel; +import java.time.Instant; import java.util.List; @Parcel @@ -35,11 +36,14 @@ public class Card extends BaseModel{ public String embedUrl; public String blurhash; public List history; + public Instant publishedAt; public transient Drawable blurhashPlaceholder; @Override public void postprocess() throws ObjectValidationException{ + if(type==null) + type=Type.LINK; super.postprocess(); if(blurhash!=null){ Bitmap placeholder=BlurHashDecoder.decode(blurhash, 16, 16); @@ -72,6 +76,7 @@ public class Card extends BaseModel{ ", embedUrl='"+embedUrl+'\''+ ", blurhash='"+blurhash+'\''+ ", history="+history+ + ", publishedAt="+publishedAt+ '}'; } diff --git a/mastodon/src/main/java/org/joinmastodon/android/model/DisplayItemsParent.java b/mastodon/src/main/java/org/joinmastodon/android/model/DisplayItemsParent.java index 911ba1ddc..25e6c408b 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/model/DisplayItemsParent.java +++ b/mastodon/src/main/java/org/joinmastodon/android/model/DisplayItemsParent.java @@ -5,4 +5,8 @@ package org.joinmastodon.android.model; */ public interface DisplayItemsParent{ String getID(); + + default String getAccountID(){ + return null; + } } diff --git a/mastodon/src/main/java/org/joinmastodon/android/model/FollowList.java b/mastodon/src/main/java/org/joinmastodon/android/model/FollowList.java new file mode 100644 index 000000000..cb6b03c31 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/model/FollowList.java @@ -0,0 +1,38 @@ +package org.joinmastodon.android.model; + +import androidx.annotation.NonNull; + +import com.google.gson.annotations.SerializedName; + +import org.joinmastodon.android.api.RequiredField; +import org.parceler.Parcel; + +@Parcel +public class FollowList extends BaseModel { + @RequiredField + public String id; + @RequiredField + public String title; + public RepliesPolicy repliesPolicy; + public boolean exclusive; + + @NonNull + @Override + public String toString() { + return "List{" + + "id='" + id + '\'' + + ", title='" + title + '\'' + + ", repliesPolicy=" + repliesPolicy + + ", exclusive=" + exclusive + + '}'; + } + + public enum RepliesPolicy{ + @SerializedName("followed") + FOLLOWED, + @SerializedName("list") + LIST, + @SerializedName("none") + NONE + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/model/Hashtag.java b/mastodon/src/main/java/org/joinmastodon/android/model/Hashtag.java index 0566ea85f..d5f923533 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/model/Hashtag.java +++ b/mastodon/src/main/java/org/joinmastodon/android/model/Hashtag.java @@ -46,4 +46,8 @@ public class Hashtag extends BaseModel implements DisplayItemsParent{ public int hashCode(){ return name.hashCode(); } + + public int getWeekPosts(){ + return history.stream().mapToInt(h->h.uses).sum(); + } } diff --git a/mastodon/src/main/java/org/joinmastodon/android/model/HeaderPaginationList.java b/mastodon/src/main/java/org/joinmastodon/android/model/HeaderPaginationList.java index 10390e316..37c56e931 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/model/HeaderPaginationList.java +++ b/mastodon/src/main/java/org/joinmastodon/android/model/HeaderPaginationList.java @@ -21,4 +21,10 @@ public class HeaderPaginationList extends ArrayList{ public HeaderPaginationList(@NonNull Collection c){ super(c); } + + public String getNextPageMaxID(){ + if(nextPageUri==null) + return null; + return nextPageUri.getQueryParameter("max_id"); + } } diff --git a/mastodon/src/main/java/org/joinmastodon/android/model/Notification.java b/mastodon/src/main/java/org/joinmastodon/android/model/Notification.java index b4340fd09..3353eea9c 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/model/Notification.java +++ b/mastodon/src/main/java/org/joinmastodon/android/model/Notification.java @@ -36,6 +36,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, diff --git a/mastodon/src/main/java/org/joinmastodon/android/model/SearchResult.java b/mastodon/src/main/java/org/joinmastodon/android/model/SearchResult.java index 6e4f4a2ed..f56b757ea 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/model/SearchResult.java +++ b/mastodon/src/main/java/org/joinmastodon/android/model/SearchResult.java @@ -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(); diff --git a/mastodon/src/main/java/org/joinmastodon/android/model/Status.java b/mastodon/src/main/java/org/joinmastodon/android/model/Status.java index eec327e16..83ebe6ff6 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/model/Status.java +++ b/mastodon/src/main/java/org/joinmastodon/android/model/Status.java @@ -55,7 +55,7 @@ public class Status extends BaseModel implements DisplayItemsParent, Searchable{ public StatusPrivacy visibility; public boolean sensitive; @RequiredField - public String spoilerText; + public String spoilerText=""; public List mediaAttachments; public Application application; @RequiredField @@ -186,6 +186,11 @@ public class Status extends BaseModel implements DisplayItemsParent, Searchable{ return id; } + @Override + public String getAccountID(){ + return getContentStatus().account.id; + } + public void update(StatusCountersUpdatedEvent ev){ favouritesCount=ev.favorites; reblogsCount=ev.reblogs; @@ -225,7 +230,10 @@ public class Status extends BaseModel implements DisplayItemsParent, Searchable{ @NonNull @Override public Status clone(){ - return (Status) super.clone(); + Status copy=(Status) super.clone(); + copy.spoilerRevealed=false; + copy.translationState=TranslationState.HIDDEN; + return copy; } public static final Pattern BOTTOM_TEXT_PATTERN = Pattern.compile("(?:[\uD83E\uDEC2\uD83D\uDC96✨\uD83E\uDD7A,]+|❤️)(?:\uD83D\uDC49\uD83D\uDC48(?:[\uD83E\uDEC2\uD83D\uDC96✨\uD83E\uDD7A,]+|❤️))*\uD83D\uDC49\uD83D\uDC48"); diff --git a/mastodon/src/main/java/org/joinmastodon/android/model/TimelineDefinition.java b/mastodon/src/main/java/org/joinmastodon/android/model/TimelineDefinition.java index ec21ded0b..789c1c5c9 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/model/TimelineDefinition.java +++ b/mastodon/src/main/java/org/joinmastodon/android/model/TimelineDefinition.java @@ -9,7 +9,6 @@ import androidx.annotation.DrawableRes; import androidx.annotation.Nullable; import androidx.annotation.StringRes; -import org.joinmastodon.android.BuildConfig; import org.joinmastodon.android.R; import org.joinmastodon.android.fragments.CustomLocalTimelineFragment; import org.joinmastodon.android.api.session.AccountSession; @@ -53,7 +52,7 @@ public class TimelineDefinition { return def; } - public static TimelineDefinition ofList(ListTimeline list) { + public static TimelineDefinition ofList(FollowList list) { return ofList(list.id, list.title, list.exclusive); } diff --git a/mastodon/src/main/java/org/joinmastodon/android/model/viewmodel/AccountViewModel.java b/mastodon/src/main/java/org/joinmastodon/android/model/viewmodel/AccountViewModel.java index 1614f728f..2df24df8c 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/model/viewmodel/AccountViewModel.java +++ b/mastodon/src/main/java/org/joinmastodon/android/model/viewmodel/AccountViewModel.java @@ -1,5 +1,6 @@ package org.joinmastodon.android.model.viewmodel; +import android.text.Spannable; import android.text.SpannableStringBuilder; import android.text.TextUtils; @@ -9,6 +10,7 @@ import org.joinmastodon.android.api.session.AccountSessionManager; import org.joinmastodon.android.model.Account; import org.joinmastodon.android.model.AccountField; import org.joinmastodon.android.ui.text.HtmlParser; +import org.joinmastodon.android.ui.text.LinkSpan; import org.joinmastodon.android.ui.utils.CustomEmojiHelper; import java.util.Collections; @@ -36,7 +38,7 @@ public class AccountViewModel{ parsedName=HtmlParser.parseCustomEmoji(account.getDisplayName(), account.emojis); else parsedName=account.getDisplayName(); - parsedBio=HtmlParser.parse(account.note, account.emojis, Collections.emptyList(), Collections.emptyList(), accountID); + parsedBio=HtmlParser.parse(account.note, account.emojis, Collections.emptyList(), Collections.emptyList(), accountID, account); SpannableStringBuilder ssb=new SpannableStringBuilder(parsedName); ssb.append(parsedBio); emojiHelper.setText(ssb); @@ -49,4 +51,13 @@ public class AccountViewModel{ } this.verifiedLink=verifiedLink; } + + public AccountViewModel stripLinksFromBio(){ + if(parsedBio instanceof Spannable spannable){ + for(LinkSpan span:spannable.getSpans(0, spannable.length(), LinkSpan.class)){ + spannable.removeSpan(span); + } + } + return this; + } } diff --git a/mastodon/src/main/java/org/joinmastodon/android/model/viewmodel/AvatarPileListItem.java b/mastodon/src/main/java/org/joinmastodon/android/model/viewmodel/AvatarPileListItem.java new file mode 100644 index 000000000..6839de1d0 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/model/viewmodel/AvatarPileListItem.java @@ -0,0 +1,22 @@ +package org.joinmastodon.android.model.viewmodel; + +import org.joinmastodon.android.R; + +import java.util.List; +import java.util.function.Consumer; + +import me.grishka.appkit.imageloader.requests.ImageLoaderRequest; + +public class AvatarPileListItem extends ListItem{ + public List avatars; + + public AvatarPileListItem(String title, String subtitle, List avatars, int iconRes, Consumer> onClick, T parentObject, boolean dividerAfter){ + super(title, subtitle, iconRes, (Consumer>)(Object)onClick, parentObject, 0, dividerAfter); + this.avatars=avatars; + } + + @Override + public int getItemViewType(){ + return R.id.list_item_avatar_pile; + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/model/viewmodel/CheckableListItem.java b/mastodon/src/main/java/org/joinmastodon/android/model/viewmodel/CheckableListItem.java index 0363f81f5..b8dde665e 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/model/viewmodel/CheckableListItem.java +++ b/mastodon/src/main/java/org/joinmastodon/android/model/viewmodel/CheckableListItem.java @@ -55,6 +55,7 @@ public class CheckableListItem extends ListItem{ case CHECKBOX -> R.id.list_item_checkbox; case RADIO -> R.id.list_item_radio; case SWITCH -> R.id.list_item_switch; + case SWITCH_SEPARATED -> R.id.list_item_switch_separated; }; } @@ -69,6 +70,7 @@ public class CheckableListItem extends ListItem{ public enum Style{ CHECKBOX, RADIO, - SWITCH + SWITCH, + SWITCH_SEPARATED } } diff --git a/mastodon/src/main/java/org/joinmastodon/android/model/viewmodel/ListItemWithOptionsMenu.java b/mastodon/src/main/java/org/joinmastodon/android/model/viewmodel/ListItemWithOptionsMenu.java new file mode 100644 index 000000000..7f3f60803 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/model/viewmodel/ListItemWithOptionsMenu.java @@ -0,0 +1,35 @@ +package org.joinmastodon.android.model.viewmodel; + +import android.view.Menu; +import android.view.MenuItem; + +import org.joinmastodon.android.R; + +import java.util.function.Consumer; + +public class ListItemWithOptionsMenu extends ListItem{ + public OptionsMenuListener listener; + + public ListItemWithOptionsMenu(String title, String subtitle, OptionsMenuListener listener, int iconRes, Consumer> onClick, T parentObject, boolean dividerAfter){ + super(title, subtitle, iconRes, (Consumer>)(Object)onClick, parentObject, 0, dividerAfter); + this.listener=listener; + } + + @Override + public int getItemViewType(){ + return R.id.list_item_options; + } + + public void performConfigureMenu(Menu menu){ + listener.onConfigureListItemOptionsMenu(this, menu); + } + + public void performItemSelected(MenuItem item){ + listener.onListItemOptionSelected(this, item); + } + + public interface OptionsMenuListener{ + void onConfigureListItemOptionsMenu(ListItemWithOptionsMenu item, Menu menu); + void onListItemOptionSelected(ListItemWithOptionsMenu item, MenuItem menuItem); + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/NonMutualPreReplySheet.java b/mastodon/src/main/java/org/joinmastodon/android/ui/NonMutualPreReplySheet.java new file mode 100644 index 000000000..a86ba1649 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/NonMutualPreReplySheet.java @@ -0,0 +1,130 @@ +package org.joinmastodon.android.ui; + +import android.annotation.SuppressLint; +import android.content.Context; +import android.text.TextUtils; +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.api.session.AccountSessionManager; +import org.joinmastodon.android.model.Account; +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{ + private boolean fullBioShown=false; + + @SuppressLint("DefaultLocale") + public NonMutualPreReplySheet(@NonNull Context context, ResultListener resultListener, Account account, String accountID){ + 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)); + if(AccountSessionManager.get(accountID).getLocalPreferences().customEmojiInNames){ + name.setText(HtmlParser.parseCustomEmoji(account.displayName, account.emojis)); + UiUtils.loadCustomEmojiInTextView(name); + }else{ + 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(!TextUtils.isEmpty(account.note)){ + CharSequence strippedBio=HtmlParser.parseCustomEmoji(HtmlParser.stripAndRemoveInvisibleSpans(account.note), account.emojis); + TextView bioShort=new TextView(context); + bioShort.setTextAppearance(R.style.m3_body_medium); + bioShort.setTextColor(UiUtils.getThemeColor(context, R.attr.colorM3Secondary)); + bioShort.setMaxLines(2); + bioShort.setEllipsize(TextUtils.TruncateAt.END); + bioShort.setText(strippedBio); + nameAndFields.addView(bioShort, new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)); + + TextView bioFull=new TextView(context); + bioFull.setTextAppearance(R.style.m3_body_medium); + bioFull.setTextColor(UiUtils.getThemeColor(context, R.attr.colorM3Secondary)); + bioFull.setText(strippedBio); + bioFull.setVisibility(View.GONE); + nameAndFields.addView(bioFull, new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)); + nameAndFields.setOnClickListener(v->{ + UiUtils.beginLayoutTransition((ViewGroup) getWindow().getDecorView()); + fullBioShown=!fullBioShown; + if(fullBioShown){ + bioFull.setVisibility(View.VISIBLE); + bioShort.setVisibility(View.GONE); + }else{ + bioFull.setVisibility(View.GONE); + bioShort.setVisibility(View.VISIBLE); + } + }); + UiUtils.loadCustomEmojiInTextView(bioShort); + UiUtils.loadCustomEmojiInTextView(bioFull); + }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); + } + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/OldPostPreReplySheet.java b/mastodon/src/main/java/org/joinmastodon/android/ui/OldPostPreReplySheet.java new file mode 100644 index 000000000..6897859cc --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/OldPostPreReplySheet.java @@ -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_fluent_clock_24_regular); + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/PreReplySheet.java b/mastodon/src/main/java/org/joinmastodon/android/ui/PreReplySheet.java new file mode 100644 index 000000000..2fb3c9a7a --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/PreReplySheet.java @@ -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); + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/SearchViewHelper.java b/mastodon/src/main/java/org/joinmastodon/android/ui/SearchViewHelper.java index 96d4c5480..53e4fd75c 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/ui/SearchViewHelper.java +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/SearchViewHelper.java @@ -48,7 +48,7 @@ public class SearchViewHelper{ searchEdit.setPadding(0, 0, 0, 0); searchEdit.addTextChangedListener(new SimpleTextWatcher(e->{ searchEdit.removeCallbacks(debouncer); - searchEdit.postDelayed(debouncer, 300); + searchEdit.postDelayed(debouncer, 500); boolean newIsEmpty=e.length()==0; if(isEmpty!=newIsEmpty){ isEmpty=newIsEmpty; diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/Snackbar.java b/mastodon/src/main/java/org/joinmastodon/android/ui/Snackbar.java new file mode 100644 index 000000000..7ee499756 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/Snackbar.java @@ -0,0 +1,217 @@ +package org.joinmastodon.android.ui; + +import android.animation.Animator; +import android.animation.AnimatorListenerAdapter; +import android.animation.AnimatorSet; +import android.animation.ObjectAnimator; +import android.content.Context; +import android.content.res.ColorStateList; +import android.graphics.Outline; +import android.graphics.PixelFormat; +import android.text.TextUtils; +import android.view.Gravity; +import android.view.View; +import android.view.ViewGroup; +import android.view.ViewOutlineProvider; +import android.view.ViewTreeObserver; +import android.view.WindowManager; +import android.view.animation.AnimationUtils; +import android.widget.Button; +import android.widget.FrameLayout; +import android.widget.LinearLayout; +import android.widget.TextView; + +import org.joinmastodon.android.R; +import org.joinmastodon.android.ui.utils.UiUtils; + +import androidx.annotation.Keep; +import androidx.annotation.StringRes; +import me.grishka.appkit.utils.CubicBezierInterpolator; +import me.grishka.appkit.utils.V; + +public class Snackbar{ + private static Snackbar current; + + private final Context context; + private int bottomOffset; + private FrameLayout windowView; + private LinearLayout contentView; + private boolean hasAction; + private AnimatableOutlineProvider outlineProvider; + private Animator currentAnim; + private Runnable dismissRunnable=this::dismiss; + + private Snackbar(Context context, String text, String action, Runnable onActionClick, int bottomOffset){ + this.context=context; + this.bottomOffset=bottomOffset; + hasAction=onActionClick!=null; + + windowView=new FrameLayout(context); + windowView.setClipToPadding(false); + contentView=new LinearLayout(context); + contentView.setOrientation(LinearLayout.HORIZONTAL); + contentView.setBaselineAligned(false); + contentView.setBackgroundColor(UiUtils.getThemeColor(context, R.attr.colorM3SurfaceInverse)); + contentView.setOutlineProvider(outlineProvider=new AnimatableOutlineProvider(contentView)); + contentView.setClipToOutline(true); + contentView.setElevation(V.dp(6)); + contentView.setPaddingRelative(V.dp(16), 0, V.dp(8), 0); + FrameLayout.LayoutParams lp=new FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT); + lp.leftMargin=lp.topMargin=lp.rightMargin=lp.bottomMargin=V.dp(16); + windowView.addView(contentView, lp); + + TextView textView=new TextView(context); + textView.setTextAppearance(R.style.m3_body_medium); + textView.setTextColor(UiUtils.getThemeColor(context, R.attr.colorM3OnSurfaceInverse)); + textView.setMaxLines(2); + textView.setEllipsize(TextUtils.TruncateAt.END); + textView.setText(text); + textView.setMinHeight(V.dp(48)); + textView.setGravity(Gravity.CENTER_VERTICAL | Gravity.START); + textView.setPadding(0, V.dp(14), 0, V.dp(14)); + contentView.addView(textView, new LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f)); + + if(action!=null){ + Button button=new Button(context); + int primaryInverse=UiUtils.getThemeColor(context, R.attr.colorM3PrimaryInverse); + button.setTextColor(primaryInverse); + button.setBackgroundResource(R.drawable.bg_rect_4dp_ripple); + button.setBackgroundTintList(ColorStateList.valueOf(primaryInverse)); + button.setText(action); + button.setPadding(V.dp(8), 0, V.dp(8), 0); + button.setOnClickListener(v->onActionClick.run()); + LinearLayout.LayoutParams blp=new LinearLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, V.dp(40)); + blp.leftMargin=blp.topMargin=blp.rightMargin=blp.bottomMargin=V.dp(4); + contentView.addView(button, blp); + } + } + + public void show(){ + if(current!=null) + current.dismiss(); + current=this; + 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; + lp.y=bottomOffset; + WindowManager wm=context.getSystemService(WindowManager.class); + wm.addView(windowView, lp); + windowView.getViewTreeObserver().addOnPreDrawListener(new ViewTreeObserver.OnPreDrawListener(){ + @Override + public boolean onPreDraw(){ + windowView.getViewTreeObserver().removeOnPreDrawListener(this); + + AnimatorSet set=new AnimatorSet(); + set.playTogether( + ObjectAnimator.ofFloat(outlineProvider, "fraction", 0, 1), + ObjectAnimator.ofFloat(contentView, View.ALPHA, 0, 1) + ); + set.setInterpolator(AnimationUtils.loadInterpolator(context, R.interpolator.m3_sys_motion_easing_standard_decelerate)); + set.setDuration(350); + set.addListener(new AnimatorListenerAdapter(){ + @Override + public void onAnimationEnd(Animator animation){ + currentAnim=null; + } + }); + currentAnim=set; + set.start(); + + return true; + } + }); + windowView.postDelayed(dismissRunnable, 4000); + } + + public void dismiss(){ + current=null; + if(currentAnim!=null){ + currentAnim.cancel(); + } + windowView.removeCallbacks(dismissRunnable); + ObjectAnimator anim=ObjectAnimator.ofFloat(contentView, View.ALPHA, 0); + anim.setInterpolator(CubicBezierInterpolator.DEFAULT); + anim.setDuration(200); + anim.addListener(new AnimatorListenerAdapter(){ + @Override + public void onAnimationEnd(Animator animation){ + WindowManager wm=context.getSystemService(WindowManager.class); + wm.removeView(windowView); + } + }); + anim.start(); + } + + private static class AnimatableOutlineProvider extends ViewOutlineProvider{ + private float fraction=1f; + private final View view; + + private AnimatableOutlineProvider(View view){ + this.view=view; + } + + @Override + public void getOutline(View view, Outline outline){ + outline.setRoundRect(0, Math.round(view.getHeight()*(1f-fraction)), view.getWidth(), view.getHeight(), V.dp(4)); + } + + @Keep + public float getFraction(){ + return fraction; + } + + @Keep + public void setFraction(float fraction){ + this.fraction=fraction; + view.invalidateOutline(); + } + } + + public static class Builder{ + private final Context context; + private String text; + private String action; + private Runnable onActionClick; + private int bottomOffset; + + public Builder(Context context){ + this.context=context; + } + + public Builder setText(String text){ + this.text=text; + return this; + } + + public Builder setText(@StringRes int res){ + text=context.getString(res); + return this; + } + + public Builder setAction(String action, Runnable onActionClick){ + this.action=action; + this.onActionClick=onActionClick; + return this; + } + + public Builder setAction(@StringRes int action, Runnable onActionClick){ + this.action=context.getString(action); + this.onActionClick=onActionClick; + return this; + } + + public Builder setBottomOffset(int offset){ + bottomOffset=offset; + return this; + } + + public Snackbar create(){ + return new Snackbar(context, text, action, onActionClick, bottomOffset); + } + + public void show(){ + create().show(); + } + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/adapters/GenericListItemsAdapter.java b/mastodon/src/main/java/org/joinmastodon/android/ui/adapters/GenericListItemsAdapter.java index d5b5655b8..bd057a428 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/ui/adapters/GenericListItemsAdapter.java +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/adapters/GenericListItemsAdapter.java @@ -3,9 +3,12 @@ package org.joinmastodon.android.ui.adapters; import android.view.ViewGroup; import org.joinmastodon.android.R; +import org.joinmastodon.android.model.viewmodel.AvatarPileListItem; import org.joinmastodon.android.model.viewmodel.ListItem; +import org.joinmastodon.android.ui.viewholders.AvatarPileListItemViewHolder; import org.joinmastodon.android.ui.viewholders.CheckboxOrRadioListItemViewHolder; import org.joinmastodon.android.ui.viewholders.ListItemViewHolder; +import org.joinmastodon.android.ui.viewholders.OptionsListItemViewHolder; import org.joinmastodon.android.ui.viewholders.SimpleListItemViewHolder; import org.joinmastodon.android.ui.viewholders.SwitchListItemViewHolder; @@ -13,11 +16,21 @@ import java.util.List; import androidx.annotation.NonNull; import androidx.recyclerview.widget.RecyclerView; +import me.grishka.appkit.imageloader.ImageLoaderRecyclerAdapter; +import me.grishka.appkit.imageloader.ListImageLoaderWrapper; +import me.grishka.appkit.imageloader.requests.ImageLoaderRequest; +import me.grishka.appkit.views.UsableRecyclerView; -public class GenericListItemsAdapter extends RecyclerView.Adapter>{ +public class GenericListItemsAdapter extends UsableRecyclerView.Adapter> implements ImageLoaderRecyclerAdapter{ private List> items; public GenericListItemsAdapter(List> items){ + super(null); + this.items=items; + } + + public GenericListItemsAdapter(ListImageLoaderWrapper imgLoader, List> items){ + super(imgLoader); this.items=items; } @@ -26,12 +39,16 @@ public class GenericListItemsAdapter extends RecyclerView.Adapter onCreateViewHolder(@NonNull ViewGroup parent, int viewType){ if(viewType==R.id.list_item_simple || viewType==R.id.list_item_simple_tinted) return new SimpleListItemViewHolder(parent.getContext(), parent); - if(viewType==R.id.list_item_switch) - return new SwitchListItemViewHolder(parent.getContext(), parent); + if(viewType==R.id.list_item_switch || viewType==R.id.list_item_switch_separated) + return new SwitchListItemViewHolder(parent.getContext(), parent, viewType==R.id.list_item_switch_separated); if(viewType==R.id.list_item_checkbox) return new CheckboxOrRadioListItemViewHolder(parent.getContext(), parent, false); if(viewType==R.id.list_item_radio) return new CheckboxOrRadioListItemViewHolder(parent.getContext(), parent, true); + if(viewType==R.id.list_item_options) + return new OptionsListItemViewHolder(parent.getContext(), parent); + if(viewType==R.id.list_item_avatar_pile) + return new AvatarPileListItemViewHolder(parent.getContext(), parent); throw new IllegalArgumentException("Unexpected view type "+viewType); } @@ -51,4 +68,20 @@ public class GenericListItemsAdapter extends RecyclerView.Adapter item=items.get(position); + if(item instanceof AvatarPileListItem avatarPileListItem) + return avatarPileListItem.avatars.size(); + return 0; + } + + @Override + public ImageLoaderRequest getImageRequest(int position, int image){ + ListItem item=items.get(position); + if(item instanceof AvatarPileListItem avatarPileListItem) + return avatarPileListItem.avatars.get(image); + return null; + } } diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/ExtendedFooterStatusDisplayItem.java b/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/ExtendedFooterStatusDisplayItem.java index c851a9e07..8d623262a 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/ExtendedFooterStatusDisplayItem.java +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/ExtendedFooterStatusDisplayItem.java @@ -2,16 +2,20 @@ package org.joinmastodon.android.ui.displayitems; import android.annotation.SuppressLint; import android.content.Context; +import android.graphics.Typeface; +import android.os.Build; import android.os.Bundle; import android.text.SpannableStringBuilder; import android.text.TextUtils; import android.text.style.ForegroundColorSpan; +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; @@ -20,26 +24,34 @@ 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; import org.joinmastodon.android.fragments.account_list.StatusFavoritesListFragment; 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; +import java.time.LocalDate; import java.time.ZoneId; +import java.time.ZonedDateTime; import java.time.format.DateTimeFormatter; import java.time.format.FormatStyle; import java.util.Locale; import androidx.annotation.PluralsRes; +import androidx.annotation.StringRes; import me.grishka.appkit.Nav; +import me.grishka.appkit.utils.V; public class ExtendedFooterStatusDisplayItem extends StatusDisplayItem{ public final String accountID; - private static final DateTimeFormatter TIME_FORMATTER=DateTimeFormatter.ofLocalizedDateTime(FormatStyle.LONG, FormatStyle.SHORT); + 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, String accountID, Status status){ super(parentID, parentFragment); @@ -53,8 +65,8 @@ public class ExtendedFooterStatusDisplayItem extends StatusDisplayItem{ } public static class Holder extends StatusDisplayItem.Holder{ - private final TextView time; - private final Button favorites, reblogs, editHistory, applicationName; + private final TextView time, date, app, dateAppSeparator; + private final TextView favorites, reblogs, editHistory; private final ImageView visibility; private final Context context; @@ -64,45 +76,50 @@ public class ExtendedFooterStatusDisplayItem extends StatusDisplayItem{ reblogs=findViewById(R.id.reblogs); favorites=findViewById(R.id.favorites); editHistory=findViewById(R.id.edit_history); - applicationName=findViewById(R.id.application_name); + time=findViewById(R.id.time); + date=findViewById(R.id.date); + app=findViewById(R.id.app_name); visibility=findViewById(R.id.visibility); - time=findViewById(R.id.timestamp); + dateAppSeparator=findViewById(R.id.date_app_separator); reblogs.setOnClickListener(v->startAccountListFragment(StatusReblogsListFragment.class)); favorites.setOnClickListener(v->startAccountListFragment(StatusFavoritesListFragment.class)); editHistory.setOnClickListener(v->startEditHistoryFragment()); + time.setOnClickListener(v->showTimeSnackbar()); + app.setOnClickListener(v->UiUtils.launchWebBrowser(context, item.status.application.website)); } @SuppressLint("DefaultLocale") @Override public void onBind(ExtendedFooterStatusDisplayItem item){ Status s=item.status; - 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); - + favorites.setText(getFormattedPlural(R.plurals.x_favorites, item.status.favouritesCount)); + reblogs.setText(getFormattedPlural(R.plurals.x_reblogs, item.status.reblogsCount)); if(s.editedAt!=null){ editHistory.setVisibility(View.VISIBLE); - editHistory.setText(UiUtils.formatRelativeTimestampAsMinutesAgo(itemView.getContext(), s.editedAt, false)); + 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)); }else{ editHistory.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); + 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); } + //TODO: make a snackbar pop up on hold of this visibility.setImageResource(switch (s.visibility) { case PUBLIC -> R.drawable.ic_fluent_earth_20_regular; case UNLISTED -> R.drawable.ic_fluent_lock_open_20_regular; @@ -117,14 +134,39 @@ public class ExtendedFooterStatusDisplayItem extends StatusDisplayItem{ return false; } - private SpannableStringBuilder getFormattedPlural(@PluralsRes int res, int quantity){ - String str=item.parentFragment.getResources().getQuantityString(res, quantity, quantity); + private SpannableStringBuilder getFormattedPlural(@PluralsRes int res, long quantity){ + String str=item.parentFragment.getResources().getQuantityString(res, (int)quantity, quantity); String formattedNumber=String.format(Locale.getDefault(), "%,d", quantity); int index=str.indexOf(formattedNumber); SpannableStringBuilder ssb=new SpannableStringBuilder(str); if(index>=0){ - ssb.setSpan(new TypefaceSpan("sans-serif-medium"), index, index+formattedNumber.length(), 0); - ssb.setSpan(new ForegroundColorSpan(UiUtils.getThemeColor(item.parentFragment.getActivity(), android.R.attr.textColorPrimary)), index, index+formattedNumber.length(), 0); + ForegroundColorSpan colorSpan=new ForegroundColorSpan(UiUtils.getThemeColor(itemView.getContext(), R.attr.colorM3OnSurfaceVariant)); + ssb.setSpan(colorSpan, index, index+formattedNumber.length(), 0); + Object typefaceSpan; + if(Build.VERSION.SDK_INT>=Build.VERSION_CODES.S){ + typefaceSpan=new TypefaceSpan(Typeface.create(Typeface.DEFAULT, 600, false)); + }else{ + typefaceSpan=new StyleSpan(Typeface.BOLD); + } + ssb.setSpan(typefaceSpan, index, index+formattedNumber.length(), 0); + } + return ssb; + } + + private SpannableStringBuilder getFormattedSubstitutedString(@StringRes int res, String substitution){ + String str=item.parentFragment.getString(res, substitution); + int index=item.parentFragment.getString(res).indexOf("%s"); + SpannableStringBuilder ssb=new SpannableStringBuilder(str); + if(index>=0){ + ForegroundColorSpan colorSpan=new ForegroundColorSpan(UiUtils.getThemeColor(itemView.getContext(), R.attr.colorM3OnSurfaceVariant)); + ssb.setSpan(colorSpan, index, index+substitution.length(), 0); + Object typefaceSpan; + if(Build.VERSION.SDK_INT>=Build.VERSION_CODES.S){ + typefaceSpan=new TypefaceSpan(Typeface.create(Typeface.DEFAULT, 600, false)); + }else{ + typefaceSpan=new StyleSpan(Typeface.BOLD); + } + ssb.setSpan(typefaceSpan, index, index+substitution.length(), 0); } return ssb; } @@ -145,5 +187,16 @@ public class ExtendedFooterStatusDisplayItem extends StatusDisplayItem{ args.putString("url", item.status.url); Nav.go(item.parentFragment.getActivity(), StatusEditHistoryFragment.class, args); } + + private void showTimeSnackbar(){ + int bottomOffset=0; + if(item.parentFragment instanceof ThreadFragment tf){ + bottomOffset=tf.getSnackbarOffset(); + } + new Snackbar.Builder(itemView.getContext()) + .setText(itemView.getContext().getString(R.string.posted_at, TIME_FORMATTER_LONG.format(item.status.createdAt.atZone(ZoneId.systemDefault())))) + .setBottomOffset(bottomOffset) + .show(); + } } } diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/LinkCardStatusDisplayItem.java b/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/LinkCardStatusDisplayItem.java index c10570454..c06ce66b6 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/LinkCardStatusDisplayItem.java +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/LinkCardStatusDisplayItem.java @@ -1,6 +1,8 @@ package org.joinmastodon.android.ui.displayitems; +import android.annotation.SuppressLint; import android.content.Context; +import android.content.res.ColorStateList; import android.graphics.drawable.Drawable; import android.net.Uri; import android.text.TextUtils; @@ -37,7 +39,7 @@ public class LinkCardStatusDisplayItem extends StatusDisplayItem{ @Override public Type getType(){ - return Type.CARD; + return status.card.type==Card.Type.VIDEO || (status.card.image!=null && status.card.width>status.card.height) ? Type.CARD_LARGE : Type.CARD_COMPACT; } @Override @@ -51,32 +53,54 @@ public class LinkCardStatusDisplayItem extends StatusDisplayItem{ } public static class Holder extends StatusDisplayItem.Holder implements ImageLoaderViewHolder{ - private final TextView title, description, domain; + private final TextView title, description, domain, timestamp; private final ImageView photo; - private final View inner; private BlurhashCrossfadeDrawable crossfadeDrawable=new BlurhashCrossfadeDrawable(); private boolean didClear; + private final View inner; + private final boolean isLarge; - public Holder(Context context, ViewGroup parent){ - super(context, R.layout.display_item_link_card, parent); + public Holder(Context context, ViewGroup parent, boolean isLarge){ + super(context, isLarge ? R.layout.display_item_link_card : R.layout.display_item_link_card_compact, parent); + this.isLarge=isLarge; title=findViewById(R.id.title); description=findViewById(R.id.description); domain=findViewById(R.id.domain); + timestamp=findViewById(R.id.timestamp); photo=findViewById(R.id.photo); inner=findViewById(R.id.inner); inner.setOnClickListener(this::onClick); + inner.setOutlineProvider(OutlineProviders.roundedRect(12)); + inner.setClipToOutline(true); } + @SuppressLint("SetTextI18n") @Override public void onBind(LinkCardStatusDisplayItem item){ Card card=item.status.card; title.setText(card.title); - description.setText(card.description); - description.setVisibility(TextUtils.isEmpty(card.description) ? View.GONE : View.VISIBLE); - domain.setText(Uri.parse(card.url).getHost()); + if(description!=null){ + description.setText(card.description); + description.setVisibility(TextUtils.isEmpty(card.description) ? View.GONE : View.VISIBLE); + } + String cardDomain=Uri.parse(card.url).getHost(); + if(isLarge && !TextUtils.isEmpty(card.authorName)){ + domain.setText(itemView.getContext().getString(R.string.article_by_author, card.authorName)+" · "+cardDomain); + }else{ + domain.setText(cardDomain); + } + if(card.publishedAt!=null){ + timestamp.setVisibility(View.VISIBLE); + timestamp.setText(" · "+UiUtils.formatRelativeTimestamp(itemView.getContext(), card.publishedAt)); + }else{ + timestamp.setVisibility(View.GONE); + } photo.setImageDrawable(null); if(item.imgRequest!=null){ + photo.setScaleType(ImageView.ScaleType.CENTER_CROP); + photo.setBackground(null); + photo.setImageTintList(null); crossfadeDrawable.setSize(card.width, card.height); if (card.width > 0) { // akkoma servers don't provide width and height @@ -85,22 +109,17 @@ public class LinkCardStatusDisplayItem extends StatusDisplayItem{ crossfadeDrawable.setSize(itemView.getWidth(), itemView.getHeight()); } crossfadeDrawable.setBlurhashDrawable(card.blurhashPlaceholder); - crossfadeDrawable.setCrossfadeAlpha(item.status.spoilerRevealed ? 0f : 1f); + crossfadeDrawable.setCrossfadeAlpha(0f); + photo.setImageDrawable(null); photo.setImageDrawable(crossfadeDrawable); photo.setVisibility(View.VISIBLE); didClear=false; } else { - photo.setVisibility(View.GONE); + 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); } - - // 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 @@ -108,6 +127,12 @@ public class LinkCardStatusDisplayItem extends StatusDisplayItem{ crossfadeDrawable.setImageDrawable(drawable); 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 + if(drawable!=null && (drawable.getIntrinsicWidth()!=card.width || drawable.getIntrinsicHeight()!=card.height)){ + photo.setImageDrawable(null); + photo.setImageDrawable(crossfadeDrawable); + } } @Override @@ -121,4 +146,3 @@ public class LinkCardStatusDisplayItem extends StatusDisplayItem{ } } } - diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/MediaGridStatusDisplayItem.java b/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/MediaGridStatusDisplayItem.java index bebf42351..fbd8a580f 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/MediaGridStatusDisplayItem.java +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/MediaGridStatusDisplayItem.java @@ -31,6 +31,7 @@ import org.joinmastodon.android.model.Translation; import org.joinmastodon.android.ui.OutlineProviders; import org.joinmastodon.android.ui.PhotoLayoutHelper; 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; diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/StatusDisplayItem.java b/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/StatusDisplayItem.java index 23de5b4b1..e08cec622 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/StatusDisplayItem.java +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/StatusDisplayItem.java @@ -125,7 +125,8 @@ public abstract class StatusDisplayItem{ case AUDIO -> new AudioStatusDisplayItem.Holder(activity, parent); case POLL_OPTION -> new PollOptionStatusDisplayItem.Holder(activity, parent); case POLL_FOOTER -> new PollFooterStatusDisplayItem.Holder(activity, parent); - case CARD -> new LinkCardStatusDisplayItem.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); @@ -392,7 +393,8 @@ public abstract class StatusDisplayItem{ AUDIO, POLL_OPTION, POLL_FOOTER, - CARD, + CARD_LARGE, + CARD_COMPACT, EMOJI_REACTIONS, FOOTER, ACCOUNT_CARD, diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/TextStatusDisplayItem.java b/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/TextStatusDisplayItem.java index b1bc84ae2..2550fdaa4 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/TextStatusDisplayItem.java +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/TextStatusDisplayItem.java @@ -68,7 +68,7 @@ public class TextStatusDisplayItem extends StatusDisplayItem{ public void setTranslatedText(String text){ Status statusForContent=status.getContentStatus(); - translatedText=HtmlParser.parse(text, statusForContent.emojis, statusForContent.mentions, statusForContent.tags, parentFragment.getAccountID()); + translatedText=HtmlParser.parse(text, statusForContent.emojis, statusForContent.mentions, statusForContent.tags, parentFragment.getAccountID(), statusForContent); translationEmojiHelper.setText(translatedText); } diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/photoviewer/AltTextSheet.java b/mastodon/src/main/java/org/joinmastodon/android/ui/photoviewer/AltTextSheet.java new file mode 100644 index 000000000..b4affaf3c --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/photoviewer/AltTextSheet.java @@ -0,0 +1,37 @@ +package org.joinmastodon.android.ui.photoviewer; + +import android.content.Context; +import android.graphics.drawable.ColorDrawable; +import android.view.LayoutInflater; +import android.view.View; +import android.widget.TextView; + +import org.joinmastodon.android.R; +import org.joinmastodon.android.model.Attachment; +import org.joinmastodon.android.ui.M3AlertDialogBuilder; +import org.joinmastodon.android.ui.utils.UiUtils; + +import androidx.annotation.NonNull; +import me.grishka.appkit.views.BottomSheet; + +public class AltTextSheet extends BottomSheet{ + public AltTextSheet(@NonNull Context context, Attachment attachment){ + super(context); + + View content=context.getSystemService(LayoutInflater.class).inflate(R.layout.sheet_alt_text, null); + setContentView(content); + TextView altText=findViewById(R.id.alt_text); + altText.setText(attachment.description); + findViewById(R.id.alt_text_help).setOnClickListener(v->showAltTextHelp()); + setNavigationBarBackground(new ColorDrawable(UiUtils.alphaBlendColors(UiUtils.getThemeColor(context, R.attr.colorM3Surface), + UiUtils.getThemeColor(context, R.attr.colorM3Primary), 0.05f)), !UiUtils.isDarkTheme()); + } + + private void showAltTextHelp(){ + new M3AlertDialogBuilder(getContext()) + .setTitle(R.string.what_is_alt_text) + .setMessage(UiUtils.fixBulletListInString(getContext(), R.string.alt_text_help)) + .setPositiveButton(R.string.ok, null) + .show(); + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/photoviewer/PhotoViewer.java b/mastodon/src/main/java/org/joinmastodon/android/ui/photoviewer/PhotoViewer.java index c249439b0..f87856b4f 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/ui/photoviewer/PhotoViewer.java +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/photoviewer/PhotoViewer.java @@ -1,6 +1,10 @@ package org.joinmastodon.android.ui.photoviewer; import android.Manifest; +import android.animation.Animator; +import android.animation.AnimatorListenerAdapter; +import android.animation.AnimatorSet; +import android.animation.ObjectAnimator; import android.annotation.SuppressLint; import android.app.Activity; import android.app.DownloadManager; @@ -13,6 +17,7 @@ import android.graphics.Insets; import android.graphics.PixelFormat; import android.graphics.Rect; import android.graphics.SurfaceTexture; +import android.graphics.drawable.BitmapDrawable; import android.graphics.drawable.ColorDrawable; import android.graphics.drawable.Drawable; import android.media.AudioManager; @@ -25,6 +30,8 @@ import android.os.SystemClock; import android.provider.MediaStore; import android.provider.Settings; import android.util.Log; +import android.util.Property; +import android.view.ContextThemeWrapper; import android.view.DisplayCutout; import android.view.Gravity; import android.view.KeyEvent; @@ -48,10 +55,14 @@ 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; @@ -91,6 +102,8 @@ public class PhotoViewer implements ZoomPanView.Listener{ private int currentIndex; private WindowManager wm; private Listener listener; + private Status status; + private String accountID; private FrameLayout windowView; private FragmentRootLinearLayout uiOverlay; @@ -111,17 +124,32 @@ public class PhotoViewer implements ZoomPanView.Listener{ if(uiVisible) toggleUI(); }; + private Animator currentSheetRelatedToolbarAnimation; private boolean videoPositionNeedsUpdating; private Runnable videoPositionUpdater=this::updateVideoPosition; private int videoDuration, videoInitialPosition, videoLastTimeUpdatePosition; private long videoInitialPositionTime; - public PhotoViewer(Activity activity, List attachments, int index, Listener listener){ + private static final Property STATUS_BAR_COLOR_PROPERTY=new Property<>(Integer.class, "Fdsafdsa"){ + @Override + public Integer get(FragmentRootLinearLayout object){ + return object.getStatusBarColor(); + } + + @Override + public void set(FragmentRootLinearLayout object, Integer value){ + object.setStatusBarColor(value); + } + }; + + public PhotoViewer(Activity activity, List attachments, int index, Status status, String accountID, Listener listener){ this.activity=activity; this.attachments=attachments.stream().filter(a->a.type==Attachment.Type.IMAGE || a.type==Attachment.Type.GIFV || a.type==Attachment.Type.VIDEO).collect(Collectors.toList()); currentIndex=index; this.listener=listener; + this.status=status; + this.accountID=accountID; wm=activity.getWindowManager(); @@ -208,12 +236,23 @@ public class PhotoViewer implements ZoomPanView.Listener{ return true; }) .setShowAsAction(MenuItem.SHOW_AS_ACTION_ALWAYS); + if(status!=null) + 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_fluent_arrow_download_24_regular).setShowAsAction(MenuItem.SHOW_AS_ACTION_ALWAYS); + toolbar.setOnMenuItemClickListener(item->{ + if(status!=null) + showInfoSheet(); + else + saveCurrentFile(); + return true; + }); uiOverlay.setAlpha(0f); videoControls=uiOverlay.findViewById(R.id.video_player_controls); videoSeekBar=uiOverlay.findViewById(R.id.seekbar); videoTimeView=uiOverlay.findViewById(R.id.time); videoPlayPauseButton=uiOverlay.findViewById(R.id.play_pause_btn); - if(attachments.get(index).type==Attachment.Type.IMAGE){ + if(attachments.get(index).type!=Attachment.Type.VIDEO){ videoControls.setVisibility(View.GONE); }else{ videoDuration=(int)Math.round(attachments.get(index).getDuration()*1000); @@ -350,7 +389,7 @@ public class PhotoViewer implements ZoomPanView.Listener{ listener.setPhotoViewVisibility(pager.getCurrentItem(), true); if(!uiVisible){ windowView.setSystemUiVisibility(windowView.getSystemUiVisibility() | View.SYSTEM_UI_FLAG_HIDE_NAVIGATION | View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY | View.SYSTEM_UI_FLAG_FULLSCREEN); - }else if(attachments.get(currentIndex).type!=Attachment.Type.IMAGE){ + }else if(attachments.get(currentIndex).type==Attachment.Type.VIDEO){ hideUiDelayed(); } } @@ -389,7 +428,7 @@ public class PhotoViewer implements ZoomPanView.Listener{ .setInterpolator(CubicBezierInterpolator.DEFAULT) .start(); windowView.setSystemUiVisibility(windowView.getSystemUiVisibility() & ~(View.SYSTEM_UI_FLAG_HIDE_NAVIGATION | View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY | View.SYSTEM_UI_FLAG_FULLSCREEN)); - if(attachments.get(currentIndex).type!=Attachment.Type.IMAGE) + if(attachments.get(currentIndex).type==Attachment.Type.VIDEO) hideUiDelayed(5000); } uiVisible=!uiVisible; @@ -408,7 +447,7 @@ public class PhotoViewer implements ZoomPanView.Listener{ currentIndex=index; Attachment att=attachments.get(index); imageDescriptionButton.setVisible(att.description != null && !att.description.isEmpty()); - V.setVisibilityAnimated(videoControls, att.type!=Attachment.Type.IMAGE ? View.VISIBLE : View.GONE); + V.setVisibilityAnimated(videoControls, att.type==Attachment.Type.VIDEO ? View.VISIBLE : View.GONE); if(att.type==Attachment.Type.VIDEO){ videoSeekBar.setSecondaryProgress(0); videoDuration=(int)Math.round(att.getDuration()*1000); @@ -643,23 +682,31 @@ public class PhotoViewer implements ZoomPanView.Listener{ } } - private MediaPlayer findCurrentVideoPlayer(){ + private GifVViewHolder findCurrentVideoPlayerHolder(){ RecyclerView rv=(RecyclerView) pager.getChildAt(0); if(rv.findViewHolderForAdapterPosition(pager.getCurrentItem()) instanceof GifVViewHolder vvh && vvh.playerReady){ - return vvh.player; + return vvh; } return null; } + private MediaPlayer findCurrentVideoPlayer(){ + GifVViewHolder holder=findCurrentVideoPlayerHolder(); + return holder!=null ? holder.player : null; + } + private void pauseVideo(){ - MediaPlayer player=findCurrentVideoPlayer(); - if(player==null || !player.isPlaying()) + GifVViewHolder holder=findCurrentVideoPlayerHolder(); + if(holder==null || !holder.player.isPlaying()) return; - player.pause(); + holder.player.pause(); videoPlayPauseButton.setImageResource(R.drawable.ic_fluent_play_24_filled); videoPlayPauseButton.setContentDescription(activity.getString(R.string.play)); stopUpdatingVideoPosition(); windowView.removeCallbacks(uiAutoHider); + // Some MediaPlayer implementations clear the texture when the app goes into background. + // This makes sure the frame on which the video was paused is retained on the screen. + holder.wrap.setBackground(new BitmapDrawable(holder.textureView.getBitmap())); } private void resumeVideo(){ @@ -694,7 +741,7 @@ public class PhotoViewer implements ZoomPanView.Listener{ private void updateVideoPosition(){ if(videoPositionNeedsUpdating){ - int currentPosition=(videoInitialPosition+(int)(SystemClock.uptimeMillis()-videoInitialPositionTime))%videoDuration; + int currentPosition=videoInitialPosition+(int)(SystemClock.uptimeMillis()-videoInitialPositionTime); videoSeekBar.setProgress(Math.round((float)currentPosition/videoDuration*10000f)); updateVideoTimeText(currentPosition); windowView.postOnAnimation(videoPositionUpdater); @@ -711,6 +758,93 @@ public class PhotoViewer implements ZoomPanView.Listener{ } } + private void showInfoSheet(){ + pauseVideo(); + PhotoViewerInfoSheet sheet=new PhotoViewerInfoSheet(new ContextThemeWrapper(activity, R.style.Theme_Mastodon_Dark), attachments.get(currentIndex), toolbar.getHeight(), new PhotoViewerInfoSheet.Listener(){ + private boolean ignoreBeforeDismiss; + + @Override + public void onBeforeDismiss(int duration){ + if(ignoreBeforeDismiss) + return; + if(currentSheetRelatedToolbarAnimation!=null) + currentSheetRelatedToolbarAnimation.cancel(); + AnimatorSet set=new AnimatorSet(); + set.playTogether( + ObjectAnimator.ofFloat(pager, View.TRANSLATION_Y, 0), + ObjectAnimator.ofFloat(toolbarWrap, View.ALPHA, 1f), + ObjectAnimator.ofArgb(uiOverlay, STATUS_BAR_COLOR_PROPERTY, 0x80000000) + ); + set.setDuration(duration); + set.setInterpolator(CubicBezierInterpolator.EASE_OUT); + currentSheetRelatedToolbarAnimation=set; + set.addListener(new AnimatorListenerAdapter(){ + @Override + public void onAnimationEnd(Animator animation){ + currentSheetRelatedToolbarAnimation=null; + } + }); + set.start(); + } + + @Override + public void onDismissEntireViewer(){ + ignoreBeforeDismiss=true; + onStartSwipeToDismissTransition(0); + } + + @Override + public void onButtonClick(int id){ + if(id==R.id.btn_boost){ + if(status!=null){ + 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, r->{}); + } + }else if(id==R.id.btn_share){ + if(status!=null){ + UiUtils.openSystemShareSheet(activity, status.url); + } + }else if(id==R.id.btn_bookmark){ + if(status!=null){ + AccountSessionManager.get(accountID).getStatusInteractionController().setBookmarked(status, !status.bookmarked); + } + }else if(id==R.id.btn_download){ + saveCurrentFile(); + } + } + }); + sheet.setStatus(status); + sheet.show(); + if(currentSheetRelatedToolbarAnimation!=null) + currentSheetRelatedToolbarAnimation.cancel(); + sheet.getWindow().getDecorView().getViewTreeObserver().addOnPreDrawListener(new ViewTreeObserver.OnPreDrawListener(){ + @Override + public boolean onPreDraw(){ + sheet.getWindow().getDecorView().getViewTreeObserver().removeOnPreDrawListener(this); + AnimatorSet set=new AnimatorSet(); + set.playTogether( + ObjectAnimator.ofFloat(pager, View.TRANSLATION_Y, -pager.getHeight()*0.2f), + ObjectAnimator.ofFloat(toolbarWrap, View.ALPHA, 0f), + ObjectAnimator.ofArgb(uiOverlay, STATUS_BAR_COLOR_PROPERTY, 0) + ); + set.setDuration(300); + set.setInterpolator(CubicBezierInterpolator.DEFAULT); + currentSheetRelatedToolbarAnimation=set; + set.addListener(new AnimatorListenerAdapter(){ + @Override + public void onAnimationEnd(Animator animation){ + currentSheetRelatedToolbarAnimation=null; + } + }); + set.start(); + return true; + } + }); + } + public interface Listener{ void setPhotoViewVisibility(int index, boolean visible); @@ -927,7 +1061,10 @@ public class PhotoViewer implements ZoomPanView.Listener{ @Override public void onSurfaceTextureUpdated(@NonNull SurfaceTexture surface){ - + // A new frame of video was rendered. Clear the thumbnail or paused frame, if any, to avoid overdraw and free up some memory. + if(player.isPlaying() && wrap.getBackground()!=null){ + wrap.setBackground(null); + } } private void startPlayer(){ @@ -935,13 +1072,12 @@ public class PhotoViewer implements ZoomPanView.Listener{ if(item.type==Attachment.Type.VIDEO){ incKeepScreenOn(); keepingScreenOn=true; - } if(getAbsoluteAdapterPosition()==currentIndex){ player.start(); startUpdatingVideoPosition(player); hideUiDelayed(); } - if (item.type == Attachment.Type.GIFV) { + }else{ keepingScreenOn=false; player.setLooping(true); player.start(); @@ -963,7 +1099,7 @@ public class PhotoViewer implements ZoomPanView.Listener{ player.setOnPreparedListener(this); player.setOnErrorListener(this); player.setOnVideoSizeChangedListener(this); - if(item.type!=Attachment.Type.IMAGE){ + if(item.type==Attachment.Type.VIDEO){ player.setOnBufferingUpdateListener(this); player.setOnInfoListener(this); player.setOnSeekCompleteListener(this); diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/photoviewer/PhotoViewerInfoSheet.java b/mastodon/src/main/java/org/joinmastodon/android/ui/photoviewer/PhotoViewerInfoSheet.java new file mode 100644 index 000000000..565d198ca --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/photoviewer/PhotoViewerInfoSheet.java @@ -0,0 +1,180 @@ +package org.joinmastodon.android.ui.photoviewer; + +import android.content.Context; +import android.content.res.ColorStateList; +import android.graphics.drawable.ColorDrawable; +import android.text.TextUtils; +import android.view.Gravity; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewOutlineProvider; +import android.view.ViewTreeObserver; +import android.widget.Button; +import android.widget.FrameLayout; +import android.widget.ImageButton; +import android.widget.TextView; + +import com.squareup.otto.Subscribe; + +import org.joinmastodon.android.E; +import org.joinmastodon.android.R; +import org.joinmastodon.android.events.StatusCountersUpdatedEvent; +import org.joinmastodon.android.model.Attachment; +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 androidx.annotation.NonNull; +import me.grishka.appkit.utils.CubicBezierInterpolator; +import me.grishka.appkit.utils.V; +import me.grishka.appkit.views.BottomSheet; + +public class PhotoViewerInfoSheet extends BottomSheet{ + private final Attachment attachment; + private final View buttonsContainer; + private final TextView altText; + private final ImageButton backButton, infoButton; + private final Button boostBtn, favoriteBtn, bookmarkBtn; + private final Listener listener; + private String statusID; + + public PhotoViewerInfoSheet(@NonNull Context context, Attachment attachment, int toolbarHeight, Listener listener){ + super(context); + this.attachment=attachment; + this.listener=listener; + + dimAmount=0; + View content=context.getSystemService(LayoutInflater.class).inflate(R.layout.sheet_photo_viewer_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()); + + buttonsContainer=findViewById(R.id.buttons_container); + altText=findViewById(R.id.alt_text); + + if(TextUtils.isEmpty(attachment.description)){ + findViewById(R.id.alt_text).setVisibility(View.GONE); + findViewById(R.id.alt_text_title).setVisibility(View.GONE); + findViewById(R.id.divider).setVisibility(View.GONE); + }else{ + altText.setText(attachment.description); + findViewById(R.id.alt_text_help).setOnClickListener(v->showAltTextHelp()); + } + + backButton=new ImageButton(context); + 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); + backButton.setElevation(V.dp(2)); + backButton.setAlpha(0f); + backButton.setOnClickListener(v->{ + listener.onDismissEntireViewer(); + dismiss(); + }); + + infoButton=new ImageButton(context); + infoButton.setImageResource(R.drawable.ic_info_fill1_24px); + infoButton.setImageTintList(ColorStateList.valueOf(UiUtils.getThemeColor(context, R.attr.colorM3OnPrimary))); + infoButton.setBackgroundResource(R.drawable.bg_button_m3_filled_icon); + infoButton.setOutlineProvider(ViewOutlineProvider.BACKGROUND); + infoButton.setElevation(V.dp(2)); + infoButton.setAlpha(0f); + infoButton.setSelected(true); + infoButton.setOnClickListener(v->dismiss()); + + FrameLayout.LayoutParams lp=new FrameLayout.LayoutParams(V.dp(48), V.dp(48)); + lp.topMargin=toolbarHeight/2-V.dp(24); + lp.leftMargin=lp.rightMargin=V.dp(4); + lp.gravity=Gravity.START | Gravity.TOP; + container.addView(backButton, lp); + + lp=new FrameLayout.LayoutParams(lp); + lp.leftMargin=lp.rightMargin=0; + lp.gravity=Gravity.END | Gravity.TOP; + container.addView(infoButton, lp); + + boostBtn=findViewById(R.id.btn_boost); + favoriteBtn=findViewById(R.id.btn_favorite); + bookmarkBtn=findViewById(R.id.btn_bookmark); + View.OnClickListener clickListener=v->listener.onButtonClick(v.getId()); + + boostBtn.setOnClickListener(clickListener); + favoriteBtn.setOnClickListener(clickListener); + findViewById(R.id.btn_share).setOnClickListener(clickListener); + bookmarkBtn.setOnClickListener(clickListener); + findViewById(R.id.btn_download).setOnClickListener(clickListener); + } + + private void showAltTextHelp(){ + new M3AlertDialogBuilder(getContext()) + .setTitle(R.string.what_is_alt_text) + .setMessage(UiUtils.fixBulletListInString(getContext(), R.string.alt_text_help)) + .setPositiveButton(R.string.ok, null) + .show(); + } + + @Override + public void dismiss(){ + if(dismissed) + return; + int height=content.getHeight(); + int duration=Math.max(60, (int) (180 * (height - content.getTranslationY()) / (float) height)); + listener.onBeforeDismiss(duration); + backButton.animate().alpha(0).setDuration(duration).setInterpolator(CubicBezierInterpolator.EASE_OUT).start(); + infoButton.animate().alpha(0).setDuration(duration).setInterpolator(CubicBezierInterpolator.EASE_OUT).start(); + super.dismiss(); + E.unregister(this); + } + + @Override + public void show(){ + super.show(); + E.register(this); + content.getViewTreeObserver().addOnPreDrawListener(new ViewTreeObserver.OnPreDrawListener(){ + @Override + public boolean onPreDraw(){ + content.getViewTreeObserver().removeOnPreDrawListener(this); + backButton.animate().alpha(1).setDuration(300).setInterpolator(CubicBezierInterpolator.DEFAULT).start(); + infoButton.animate().alpha(1).setDuration(300).setInterpolator(CubicBezierInterpolator.DEFAULT).start(); + return true; + } + }); + } + + public void setStatus(Status status){ + statusID=status.id; + boostBtn.setCompoundDrawablesWithIntrinsicBounds(0, switch(status.visibility){ + case DIRECT -> R.drawable.ic_boost_disabled_24px; + case PUBLIC, UNLISTED, LOCAL -> R.drawable.ic_boost; + case PRIVATE -> R.drawable.ic_boost_private; + }, 0, 0); + boostBtn.setEnabled(status.visibility!=StatusPrivacy.DIRECT); + setButtonStates(status.reblogged, status.favourited, status.bookmarked); + } + + @Subscribe + public void onCountersUpdated(StatusCountersUpdatedEvent ev){ + if(ev.id.equals(statusID)){ + setButtonStates(ev.reblogged, ev.favorited, ev.bookmarked); + } + } + + private void setButtonStates(boolean reblogged, boolean favorited, boolean bookmarked){ + boostBtn.setText(reblogged ? R.string.button_reblogged : R.string.button_reblog); + boostBtn.setSelected(reblogged); + + favoriteBtn.setText(favorited ? R.string.button_favorited : R.string.button_favorite); + favoriteBtn.setSelected(favorited); + + bookmarkBtn.setText(bookmarked ? R.string.bookmarked : R.string.add_bookmark); + bookmarkBtn.setSelected(bookmarked); + } + + public interface Listener{ + void onBeforeDismiss(int duration); + void onDismissEntireViewer(); + void onButtonClick(int id); + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/sheets/AccountRestrictionConfirmationSheet.java b/mastodon/src/main/java/org/joinmastodon/android/ui/sheets/AccountRestrictionConfirmationSheet.java new file mode 100644 index 000000000..accda39a0 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/sheets/AccountRestrictionConfirmationSheet.java @@ -0,0 +1,90 @@ +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); + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/AccountSwitcherSheet.java b/mastodon/src/main/java/org/joinmastodon/android/ui/sheets/AccountSwitcherSheet.java similarity index 93% rename from mastodon/src/main/java/org/joinmastodon/android/ui/AccountSwitcherSheet.java rename to mastodon/src/main/java/org/joinmastodon/android/ui/sheets/AccountSwitcherSheet.java index ea8603757..460db1ec1 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/ui/AccountSwitcherSheet.java +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/sheets/AccountSwitcherSheet.java @@ -1,4 +1,4 @@ -package org.joinmastodon.android.ui; +package org.joinmastodon.android.ui.sheets; import android.annotation.SuppressLint; import android.app.Activity; @@ -8,7 +8,6 @@ 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; @@ -27,8 +26,10 @@ import org.joinmastodon.android.api.session.AccountSessionManager; import org.joinmastodon.android.fragments.HomeFragment; import org.joinmastodon.android.fragments.SplashFragment; import org.joinmastodon.android.fragments.onboarding.CustomWelcomeFragment; +import org.joinmastodon.android.ui.ClickableSingleViewRecyclerAdapter; +import org.joinmastodon.android.ui.M3AlertDialogBuilder; +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.CheckableRelativeLayout; @@ -67,6 +68,7 @@ public class AccountSwitcherSheet extends BottomSheet{ private List accounts; private ListImageLoaderWrapper imgLoader; private AccountsAdapter accountsAdapter; + private Runnable onLoggedOutCallback; public AccountSwitcherSheet(@NonNull Activity activity, @Nullable HomeFragment fragment){ this(activity, fragment, false, false); @@ -125,6 +127,11 @@ public class AccountSwitcherSheet extends BottomSheet{ UiUtils.getThemeColor(activity, R.attr.colorM3Primary), 0.05f)), !UiUtils.isDarkTheme()); } + public AccountSwitcherSheet setOnLoggedOutCallback(Runnable onLoggedOutCallback){ + this.onLoggedOutCallback=onLoggedOutCallback; + return this; + } + public void setOnClick(BiConsumer onClick) { this.onClick = onClick; } @@ -148,8 +155,12 @@ public class AccountSwitcherSheet extends BottomSheet{ } private void logOut(String accountID){ + String activeAccount=AccountSessionManager.getInstance().getLastActiveAccountID(); AccountSessionManager.get(accountID).logOut(activity, ()->{ - ((MainActivity)activity).restartActivity(); + if(accountID.equals(activeAccount) && onLoggedOutCallback!=null) + onLoggedOutCallback.run(); + dismiss(); + ((MainActivity)activity).restartHomeFragment(); }); } @@ -167,6 +178,8 @@ public class AccountSwitcherSheet extends BottomSheet{ AccountSessionManager.getInstance().removeAccount(session.getID()); sessions.remove(session); if(sessions.isEmpty()){ + if(onLoggedOutCallback!=null) + onLoggedOutCallback.run(); progress.dismiss(); Nav.goClearingStack(activity, SplashFragment.class, null); dismiss(); @@ -178,6 +191,8 @@ public class AccountSwitcherSheet extends BottomSheet{ AccountSessionManager.getInstance().removeAccount(session.getID()); sessions.remove(session); if(sessions.isEmpty()){ + if(onLoggedOutCallback!=null) + onLoggedOutCallback.run(); progress.dismiss(); Nav.goClearingStack(activity, SplashFragment.class, null); dismiss(); diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/sheets/BlockAccountConfirmationSheet.java b/mastodon/src/main/java/org/joinmastodon/android/ui/sheets/BlockAccountConfirmationSheet.java new file mode 100644 index 000000000..3097cf3f5 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/sheets/BlockAccountConfirmationSheet.java @@ -0,0 +1,24 @@ +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_fluent_shield_24_regular); + subtitleView.setText(user.getDisplayUsername()); + addRow(R.drawable.ic_campaign_24px, R.string.user_can_see_blocked); + addRow(R.drawable.ic_fluent_eye_off_24_regular, R.string.user_cant_see_each_other_posts); + addRow(R.drawable.ic_fluent_mention_24_regular, R.string.you_wont_see_user_mentions); + addRow(R.drawable.ic_fluent_arrow_reply_24_regular, R.string.user_cant_mention_or_follow_you); + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/sheets/DecentralizationExplainerSheet.java b/mastodon/src/main/java/org/joinmastodon/android/ui/sheets/DecentralizationExplainerSheet.java new file mode 100644 index 000000000..c15f201b1 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/sheets/DecentralizationExplainerSheet.java @@ -0,0 +1,101 @@ +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(); + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/sheets/MuteAccountConfirmationSheet.java b/mastodon/src/main/java/org/joinmastodon/android/ui/sheets/MuteAccountConfirmationSheet.java new file mode 100644 index 000000000..87522e1bf --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/sheets/MuteAccountConfirmationSheet.java @@ -0,0 +1,25 @@ +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); + //TODO: readd the time option + titleView.setText(R.string.mute_user_confirm_title); + confirmBtn.setText(R.string.do_mute); + secondaryBtn.setVisibility(View.GONE); + icon.setImageResource(R.drawable.ic_fluent_speaker_off_24_regular); + subtitleView.setText(user.getDisplayUsername()); + addRow(R.drawable.ic_campaign_24px, R.string.user_wont_know_muted); + addRow(R.drawable.ic_fluent_eye_off_24_regular, R.string.user_can_still_see_your_posts); + addRow(R.drawable.ic_fluent_mention_24_regular, R.string.you_wont_see_user_mentions); + addRow(R.drawable.ic_fluent_arrow_reply_24_regular, R.string.user_can_mention_and_follow_you); + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/sheets/NonMutualPreReplySheet.java b/mastodon/src/main/java/org/joinmastodon/android/ui/sheets/NonMutualPreReplySheet.java new file mode 100644 index 000000000..9e358be17 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/sheets/NonMutualPreReplySheet.java @@ -0,0 +1,131 @@ +package org.joinmastodon.android.ui.sheets; + +import android.annotation.SuppressLint; +import android.content.Context; +import android.text.TextUtils; +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.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; + +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{ + private boolean fullBioShown=false; + + @SuppressLint("DefaultLocale") + public NonMutualPreReplySheet(@NonNull Context context, ResultListener resultListener, Account account, String accountID){ + 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)); + if(AccountSessionManager.get(accountID).getLocalPreferences().customEmojiInNames){ + name.setText(HtmlParser.parseCustomEmoji(account.displayName, account.emojis)); + UiUtils.loadCustomEmojiInTextView(name); + }else{ + 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(!TextUtils.isEmpty(account.note)){ + CharSequence strippedBio=HtmlParser.parseCustomEmoji(HtmlParser.stripAndRemoveInvisibleSpans(account.note), account.emojis); + TextView bioShort=new TextView(context); + bioShort.setTextAppearance(R.style.m3_body_medium); + bioShort.setTextColor(UiUtils.getThemeColor(context, R.attr.colorM3Secondary)); + bioShort.setMaxLines(2); + bioShort.setEllipsize(TextUtils.TruncateAt.END); + bioShort.setText(strippedBio); + nameAndFields.addView(bioShort, new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)); + + TextView bioFull=new TextView(context); + bioFull.setTextAppearance(R.style.m3_body_medium); + bioFull.setTextColor(UiUtils.getThemeColor(context, R.attr.colorM3Secondary)); + bioFull.setText(strippedBio); + bioFull.setVisibility(View.GONE); + nameAndFields.addView(bioFull, new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)); + nameAndFields.setOnClickListener(v->{ + UiUtils.beginLayoutTransition((ViewGroup) getWindow().getDecorView()); + fullBioShown=!fullBioShown; + if(fullBioShown){ + bioFull.setVisibility(View.VISIBLE); + bioShort.setVisibility(View.GONE); + }else{ + bioFull.setVisibility(View.GONE); + bioShort.setVisibility(View.VISIBLE); + } + }); + UiUtils.loadCustomEmojiInTextView(bioShort); + UiUtils.loadCustomEmojiInTextView(bioFull); + }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); + } + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/sheets/OldPostPreReplySheet.java b/mastodon/src/main/java/org/joinmastodon/android/ui/sheets/OldPostPreReplySheet.java new file mode 100644 index 000000000..644ae1865 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/sheets/OldPostPreReplySheet.java @@ -0,0 +1,23 @@ +package org.joinmastodon.android.ui.sheets; + +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_fluent_history_24_regular); + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/sheets/PreReplySheet.java b/mastodon/src/main/java/org/joinmastodon/android/ui/sheets/PreReplySheet.java new file mode 100644 index 000000000..cae173a5e --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/sheets/PreReplySheet.java @@ -0,0 +1,54 @@ +package org.joinmastodon.android.ui.sheets; + +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); + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/text/HtmlParser.java b/mastodon/src/main/java/org/joinmastodon/android/ui/text/HtmlParser.java index d47942054..832e8905c 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/ui/text/HtmlParser.java +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/text/HtmlParser.java @@ -66,12 +66,21 @@ public class HtmlParser{ ")" + ")"; public static final Pattern URL_PATTERN=Pattern.compile(VALID_URL_PATTERN_STRING, Pattern.CASE_INSENSITIVE); + public static final Pattern INVITE_LINK_PATTERN=Pattern.compile("^https://"+Regex.URL_VALID_DOMAIN+"/invite/[a-z\\d]+$", Pattern.CASE_INSENSITIVE); private static Pattern EMOJI_CODE_PATTERN=Pattern.compile(":([\\w]+):"); private HtmlParser(){} public static SpannableStringBuilder parse(String source, List emojis, List mentions, List tags, String accountID){ - return parse(source, emojis, mentions, tags, accountID, null); + return parse(source, emojis, mentions, tags, accountID, null, null); + } + + public static SpannableStringBuilder parse(String source, List emojis, List mentions, List tags, String accountID, Context context){ + return parse(source, emojis, mentions, tags, accountID, null, context); + } + + public static SpannableStringBuilder parse(String source, List emojis, List mentions, List tags, String accountID, Object parentObject){ + return parse(source, emojis, mentions, tags, accountID, parentObject, null); } /** @@ -86,7 +95,7 @@ public class HtmlParser{ * @param emojis Custom emojis that are present in source as :code: * @return a spanned string */ - public static SpannableStringBuilder parse(String source, List emojis, List mentions, List tags, String accountID, Context context){ + public static SpannableStringBuilder parse(String source, List emojis, List mentions, List tags, String accountID, Object parentObject, Context context){ class SpanInfo{ public Object span; public int start; @@ -105,7 +114,7 @@ public class HtmlParser{ } } - Map idsByUrl=mentions.stream().filter(mention -> mention.id != null).collect(Collectors.toMap(m->m.url, m->m.id)); + Map idsByUrl=mentions.stream().distinct().collect(Collectors.toMap(m->m.url, m->m.id)); // Hashtags in remote posts have remote URLs, these have local URLs so they don't match. // Map tagsByUrl=tags.stream().collect(Collectors.toMap(t->t.url, t->t.name)); Map tagsByTag=tags.stream().distinct().collect(Collectors.toMap(t->t.name.toLowerCase(), Function.identity())); @@ -120,15 +129,15 @@ public class HtmlParser{ @Override public void head(@NonNull Node node, int depth){ if(node instanceof TextNode textNode){ - ssb.append(textNode.getWholeText()); + ssb.append(textNode.text()); }else if(node instanceof Element el){ switch(el.nodeName()){ case "a" -> { Object linkObject=null; String href=el.attr("href"); LinkSpan.Type linkType; - String text=el.text(); if(el.hasClass("hashtag")){ + String text=el.text(); if(text.startsWith("#")){ linkType=LinkSpan.Type.HASHTAG; href=text.substring(1); @@ -147,7 +156,7 @@ public class HtmlParser{ }else{ linkType=LinkSpan.Type.URL; } - openSpans.add(new SpanInfo(new LinkSpan(href, null, linkType, accountID, linkObject, text), ssb.length(), el)); + openSpans.add(new SpanInfo(new LinkSpan(href, null, linkType, accountID, linkObject, parentObject), ssb.length(), el)); } case "br" -> ssb.append('\n'); case "span" -> { @@ -269,8 +278,28 @@ public class HtmlParser{ public static String stripAndRemoveInvisibleSpans(String html){ Document doc=Jsoup.parseBodyFragment(html); doc.body().select("span.invisible").remove(); - Cleaner cleaner=new Cleaner(Safelist.none()); - return cleaner.clean(doc).body().html(); + Cleaner cleaner=new Cleaner(Safelist.none().addTags("br", "p")); + StringBuilder sb=new StringBuilder(); + cleaner.clean(doc).body().traverse(new NodeVisitor(){ + @Override + public void head(Node node, int depth){ + if(node instanceof TextNode tn){ + sb.append(tn.text()); + }else if(node instanceof Element el){ + if("br".equals(el.tagName())){ + sb.append('\n'); + } + } + } + + @Override + public void tail(Node node, int depth){ + if(node instanceof Element el && "p".equals(el.tagName()) && el.nextSibling()!=null){ + sb.append("\n\n"); + } + } + }); + return sb.toString(); } public static String text(String html) { diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/text/LinkSpan.java b/mastodon/src/main/java/org/joinmastodon/android/ui/text/LinkSpan.java index f654f6b34..b907a9f6d 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/ui/text/LinkSpan.java +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/text/LinkSpan.java @@ -17,15 +17,15 @@ public class LinkSpan extends CharacterStyle { private Type type; private String accountID; private Object linkObject; - private String text; + private Object parentObject; - public LinkSpan(String link, OnLinkClickListener listener, Type type, String accountID, Object linkObject, String text){ + public LinkSpan(String link, OnLinkClickListener listener, Type type, String accountID, Object linkObject, Object parentObject){ this.listener=listener; this.link=link; this.type=type; this.accountID=accountID; this.linkObject=linkObject; - this.text=text; + this.parentObject=parentObject; } public int getColor(){ @@ -40,7 +40,7 @@ public class LinkSpan extends CharacterStyle { public void onClick(Context context){ switch(getType()){ - case URL -> UiUtils.openURL(context, accountID, link); + case URL -> UiUtils.openURL(context, accountID, link, parentObject); case MENTION -> UiUtils.openProfileByID(context, accountID, link); case HASHTAG -> { if(linkObject instanceof Hashtag ht) @@ -53,7 +53,10 @@ public class LinkSpan extends CharacterStyle { } public void onLongClick(View view) { - UiUtils.copyText(view, getType() == Type.URL ? link : text); + if(linkObject instanceof Hashtag ht) + UiUtils.copyText(view, ht.name); + else + UiUtils.copyText(view, link); } public String getLink(){ @@ -61,7 +64,7 @@ public class LinkSpan extends CharacterStyle { } public String getText() { - return text; + return parentObject.toString(); } public Type getType(){ diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/utils/ActionModeHelper.java b/mastodon/src/main/java/org/joinmastodon/android/ui/utils/ActionModeHelper.java index 23f7fbf45..7e8bb310e 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/ui/utils/ActionModeHelper.java +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/utils/ActionModeHelper.java @@ -9,6 +9,7 @@ import android.view.ActionMode; import android.view.Menu; import android.view.MenuItem; import android.view.View; +import android.view.ViewGroup; import org.joinmastodon.android.R; diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/utils/DiscoverInfoBannerHelper.java b/mastodon/src/main/java/org/joinmastodon/android/ui/utils/DiscoverInfoBannerHelper.java index e69acc17f..c2330b5d9 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/ui/utils/DiscoverInfoBannerHelper.java +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/utils/DiscoverInfoBannerHelper.java @@ -23,6 +23,8 @@ public class DiscoverInfoBannerHelper{ private final BannerType type; private final String accountID; private static EnumSet bannerTypesToShow=EnumSet.noneOf(BannerType.class); + private SingleViewRecyclerAdapter bannerAdapter; + private boolean added; static{ for(BannerType t:BannerType.values()){ @@ -41,6 +43,8 @@ public class DiscoverInfoBannerHelper{ } public void maybeAddBanner(RecyclerView list, MergeRecyclerAdapter adapter){ + if(added) + return; if(bannerTypesToShow.contains(type)){ banner=((Activity)list.getContext()).getLayoutInflater().inflate(R.layout.discover_info_banner, list, false); TextView text=banner.findViewById(R.id.banner_text); @@ -63,7 +67,8 @@ public class DiscoverInfoBannerHelper{ case BUBBLE_TIMELINE -> TimelineDefinition.BUBBLE_TIMELINE.getDefaultIcon().iconRes; case POST_NOTIFICATIONS -> TimelineDefinition.POSTS_TIMELINE.getDefaultIcon().iconRes; }); - adapter.addAdapter(new SingleViewRecyclerAdapter(banner)); + adapter.addAdapter(0, bannerAdapter=new SingleViewRecyclerAdapter(banner)); + added=true; } } @@ -72,6 +77,13 @@ public class DiscoverInfoBannerHelper{ // bannerTypesToShow is not updated here on purpose so the banner keeps showing until the app is relaunched } + public void removeBanner(MergeRecyclerAdapter adapter){ + if(bannerAdapter!=null){ + adapter.removeAdapter(bannerAdapter); + added=false; + } + } + public static void reset(){ SharedPreferences prefs=getPrefs(); SharedPreferences.Editor e=prefs.edit(); diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/utils/UiUtils.java b/mastodon/src/main/java/org/joinmastodon/android/ui/utils/UiUtils.java index a831c9c37..284371bb9 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/ui/utils/UiUtils.java +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/utils/UiUtils.java @@ -23,9 +23,11 @@ import android.content.res.TypedArray; import android.database.Cursor; import android.graphics.Bitmap; import android.graphics.Canvas; +import android.graphics.drawable.Animatable; import android.graphics.drawable.BitmapDrawable; import android.graphics.drawable.Drawable; import android.graphics.drawable.InsetDrawable; +import android.graphics.drawable.LayerDrawable; import android.net.Uri; import android.os.Build; import android.os.Bundle; @@ -38,6 +40,7 @@ import android.provider.OpenableColumns; import android.text.SpannableStringBuilder; import android.text.Spanned; import android.text.TextUtils; +import android.text.style.BulletSpan; import android.text.style.TypefaceSpan; import android.transition.ChangeBounds; import android.transition.ChangeScroll; @@ -49,6 +52,8 @@ import android.util.Pair; import android.view.Gravity; import android.view.HapticFeedbackConstants; import android.view.LayoutInflater; +import android.view.Gravity; +import android.view.LayoutInflater; import android.view.Menu; import android.view.MenuItem; import android.view.SubMenu; @@ -58,14 +63,17 @@ import android.view.ViewPropertyAnimator; import android.view.WindowInsets; import android.webkit.MimeTypeMap; import android.widget.Button; +import android.widget.FrameLayout; import android.widget.ImageView; import android.widget.LinearLayout; import android.widget.PopupMenu; +import android.widget.ProgressBar; import android.widget.TextView; import android.widget.Toast; import org.joinmastodon.android.E; import org.joinmastodon.android.GlobalUserPreferences; +import org.joinmastodon.android.MainActivity; import org.joinmastodon.android.MastodonApp; import org.joinmastodon.android.R; import org.joinmastodon.android.api.CacheController; @@ -76,6 +84,7 @@ import org.joinmastodon.android.api.requests.accounts.SetAccountBlocked; import org.joinmastodon.android.api.requests.accounts.SetAccountFollowed; import org.joinmastodon.android.api.requests.accounts.SetAccountMuted; import org.joinmastodon.android.api.requests.accounts.SetDomainBlocked; +import org.joinmastodon.android.api.requests.search.GetSearchResults; import org.joinmastodon.android.api.requests.accounts.AuthorizeFollowRequest; import org.joinmastodon.android.api.requests.accounts.RejectFollowRequest; import org.joinmastodon.android.api.requests.instance.GetInstance; @@ -110,11 +119,15 @@ import org.joinmastodon.android.model.Instance; import org.joinmastodon.android.model.Notification; import org.joinmastodon.android.model.Hashtag; import org.joinmastodon.android.model.Relationship; +import org.joinmastodon.android.model.SearchResults; import org.joinmastodon.android.model.ScheduledStatus; import org.joinmastodon.android.model.SearchResults; import org.joinmastodon.android.model.Searchable; import org.joinmastodon.android.model.Status; import org.joinmastodon.android.ui.M3AlertDialogBuilder; +import org.joinmastodon.android.ui.Snackbar; +import org.joinmastodon.android.ui.sheets.BlockAccountConfirmationSheet; +import org.joinmastodon.android.ui.sheets.MuteAccountConfirmationSheet; import org.joinmastodon.android.ui.text.CustomEmojiSpan; import org.joinmastodon.android.ui.text.HtmlParser; import org.parceler.Parcels; @@ -141,6 +154,7 @@ import java.util.List; import java.util.Locale; import java.util.Map; import java.util.Objects; +import java.util.Objects; import java.util.Optional; import java.util.concurrent.atomic.AtomicReference; import java.util.function.BiConsumer; @@ -488,30 +502,44 @@ public class UiUtils { } public static void confirmToggleBlockUser(Activity activity, String accountID, Account account, boolean currentlyBlocked, Consumer resultCallback) { - showConfirmationAlert(activity, activity.getString(currentlyBlocked ? R.string.confirm_unblock_title : R.string.confirm_block_title), - activity.getString(currentlyBlocked ? R.string.confirm_unblock : R.string.confirm_block, account.getDisplayName()), - activity.getString(currentlyBlocked ? R.string.do_unblock : R.string.do_block), - R.drawable.ic_fluent_person_prohibited_28_regular, - () -> { - new SetAccountBlocked(account.id, !currentlyBlocked) - .setCallback(new Callback<>() { - @Override - public void onSuccess(Relationship result) { - if (activity == null) return; - resultCallback.accept(result); - if (!currentlyBlocked) { - E.post(new RemoveAccountPostsEvent(accountID, account.id, false)); - } - } + if(!currentlyBlocked){ + new BlockAccountConfirmationSheet(activity, account, (onSuccess, onError)->{ + new SetAccountBlocked(account.id, true) + .setCallback(new Callback<>(){ + @Override + public void onSuccess(Relationship result){ + resultCallback.accept(result); + onSuccess.run(); + E.post(new RemoveAccountPostsEvent(accountID, account.id, false)); + } - @Override - public void onError(ErrorResponse error) { - error.showToast(activity); - } - }) - .wrapProgress(activity, R.string.loading, false) - .exec(accountID); - }); + @Override + public void onError(ErrorResponse error){ + error.showToast(activity); + onError.run(); + } + }) + .exec(accountID); + }).show(); + }else{ + new SetAccountBlocked(account.id, false) + .setCallback(new Callback<>(){ + @Override + public void onSuccess(Relationship result){ + resultCallback.accept(result); + new Snackbar.Builder(activity) + .setText(activity.getString(R.string.unblocked_user_x, account.getDisplayUsername())) + .show(); + } + + @Override + public void onError(ErrorResponse error){ + error.showToast(activity); + } + }) + .wrapProgress(activity, R.string.loading, false) + .exec(accountID); + } } public static void confirmSoftBlockUser(Activity activity, String accountID, Account account, Consumer resultCallback) { @@ -570,69 +598,109 @@ public class UiUtils { }); } public static void confirmToggleMuteUser(Context context, String accountID, Account account, boolean currentlyMuted, Consumer resultCallback){ - View durationView=LayoutInflater.from(context).inflate(R.layout.mute_user_dialog, null); - LinearLayout.LayoutParams params=new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT); - params.setMargins(0, V.dp(-12), 0, 0); - durationView.setLayoutParams(params); - Button button=durationView.findViewById(R.id.button); - ((TextView) durationView.findViewById(R.id.message)).setText(context.getString(R.string.confirm_mute, account.getDisplayName())); + if(!currentlyMuted){ + new MuteAccountConfirmationSheet(context, account, (onSuccess, onError)->{ + new SetAccountMuted(account.id, true, 0) + .setCallback(new Callback<>(){ + @Override + public void onSuccess(Relationship result){ + resultCallback.accept(result); + onSuccess.run(); + E.post(new RemoveAccountPostsEvent(accountID, account.id, false)); + } - AtomicReference muteDuration=new AtomicReference<>(Duration.ZERO); + @Override + public void onError(ErrorResponse error){ + error.showToast(context); + onError.run(); + } + }) + .exec(accountID); + }).show(); + }else{ + new SetAccountMuted(account.id, false, 0) + .setCallback(new Callback<>(){ + @Override + public void onSuccess(Relationship result){ + resultCallback.accept(result); + new Snackbar.Builder(context) + .setText(context.getString(R.string.unmuted_user_x, account.getDisplayUsername())) + .show(); + } - PopupMenu popupMenu=new PopupMenu(context, button, Gravity.CENTER_HORIZONTAL); - popupMenu.inflate(R.menu.mute_duration); - popupMenu.setOnMenuItemClickListener(item->{ - int id=item.getItemId(); - if(id==R.id.duration_indefinite) - muteDuration.set(Duration.ZERO); - else if(id==R.id.duration_minutes_5){ - muteDuration.set(Duration.ofMinutes(5)); - }else if(id==R.id.duration_minutes_30){ - muteDuration.set(Duration.ofMinutes(30)); - }else if(id==R.id.duration_hours_1){ - muteDuration.set(Duration.ofHours(1)); - }else if(id==R.id.duration_hours_6){ - muteDuration.set(Duration.ofHours(6)); - }else if(id==R.id.duration_days_1){ - muteDuration.set(Duration.ofDays(1)); - }else if(id==R.id.duration_days_3){ - muteDuration.set(Duration.ofDays(3)); - }else if(id==R.id.duration_days_7){ - muteDuration.set(Duration.ofDays(7)); - } - button.setText(item.getTitle()); - return true; - }); - button.setOnTouchListener(popupMenu.getDragToOpenListener()); - button.setOnClickListener(v->popupMenu.show()); - button.setText(popupMenu.getMenu().getItem(0).getTitle()); + @Override + public void onError(ErrorResponse error){ + error.showToast(context); + } + }) + .wrapProgress(context, R.string.loading, false) + .exec(accountID); + } - new M3AlertDialogBuilder(context) - .setTitle(context.getString(currentlyMuted ? R.string.confirm_unmute_title : R.string.confirm_mute_title)) - .setMessage(currentlyMuted ? context.getString(R.string.confirm_unmute, account.getDisplayName()) : null) - .setView(currentlyMuted ? null : durationView) - .setPositiveButton(context.getString(currentlyMuted ? R.string.do_unmute : R.string.do_mute), (dlg, i)->{ - new SetAccountMuted(account.id, !currentlyMuted, muteDuration.get().getSeconds()) - .setCallback(new Callback<>(){ - @Override - public void onSuccess(Relationship result){ - resultCallback.accept(result); - if(!currentlyMuted){ - E.post(new RemoveAccountPostsEvent(accountID, account.id, false)); - } - } - - @Override - public void onError(ErrorResponse error){ - error.showToast(context); - } - }) - .wrapProgress(context, R.string.loading, false) - .exec(accountID); - }) - .setNegativeButton(R.string.cancel, null) - .setIcon(currentlyMuted ? R.drawable.ic_fluent_speaker_2_28_regular : R.drawable.ic_fluent_speaker_off_28_regular) - .show(); + // I need to readd the mute thing, so this is gonna stay as a comment for now +// View durationView=LayoutInflater.from(context).inflate(R.layout.mute_user_dialog, null); +// LinearLayout.LayoutParams params=new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT); +// params.setMargins(0, V.dp(-12), 0, 0); +// durationView.setLayoutParams(params); +// Button button=durationView.findViewById(R.id.button); +// ((TextView) durationView.findViewById(R.id.message)).setText(context.getString(R.string.confirm_mute, account.getDisplayName())); +// +// AtomicReference muteDuration=new AtomicReference<>(Duration.ZERO); +// +// PopupMenu popupMenu=new PopupMenu(context, button, Gravity.CENTER_HORIZONTAL); +// popupMenu.inflate(R.menu.mute_duration); +// popupMenu.setOnMenuItemClickListener(item->{ +// int id=item.getItemId(); +// if(id==R.id.duration_indefinite) +// muteDuration.set(Duration.ZERO); +// else if(id==R.id.duration_minutes_5){ +// muteDuration.set(Duration.ofMinutes(5)); +// }else if(id==R.id.duration_minutes_30){ +// muteDuration.set(Duration.ofMinutes(30)); +// }else if(id==R.id.duration_hours_1){ +// muteDuration.set(Duration.ofHours(1)); +// }else if(id==R.id.duration_hours_6){ +// muteDuration.set(Duration.ofHours(6)); +// }else if(id==R.id.duration_days_1){ +// muteDuration.set(Duration.ofDays(1)); +// }else if(id==R.id.duration_days_3){ +// muteDuration.set(Duration.ofDays(3)); +// }else if(id==R.id.duration_days_7){ +// muteDuration.set(Duration.ofDays(7)); +// } +// button.setText(item.getTitle()); +// return true; +// }); +// button.setOnTouchListener(popupMenu.getDragToOpenListener()); +// button.setOnClickListener(v->popupMenu.show()); +// button.setText(popupMenu.getMenu().getItem(0).getTitle()); +// +// new M3AlertDialogBuilder(context) +// .setTitle(context.getString(currentlyMuted ? R.string.confirm_unmute_title : R.string.confirm_mute_title)) +// .setMessage(currentlyMuted ? context.getString(R.string.confirm_unmute, account.getDisplayName()) : null) +// .setView(currentlyMuted ? null : durationView) +// .setPositiveButton(context.getString(currentlyMuted ? R.string.do_unmute : R.string.do_mute), (dlg, i)->{ +// new SetAccountMuted(account.id, !currentlyMuted, muteDuration.get().getSeconds()) +// .setCallback(new Callback<>(){ +// @Override +// public void onSuccess(Relationship result){ +// resultCallback.accept(result); +// if(!currentlyMuted){ +// E.post(new RemoveAccountPostsEvent(accountID, account.id, false)); +// } +// } +// +// @Override +// public void onError(ErrorResponse error){ +// error.showToast(context); +// } +// }) +// .wrapProgress(context, R.string.loading, false) +// .exec(accountID); +// }) +// .setNegativeButton(R.string.cancel, null) +// .setIcon(currentlyMuted ? R.drawable.ic_fluent_speaker_2_28_regular : R.drawable.ic_fluent_speaker_off_28_regular) +// .show(); } public static void confirmDeletePost(Activity activity, String accountID, Status status, Consumer resultCallback, boolean forRedraft) { @@ -769,8 +837,9 @@ public class UiUtils { activity.getString(R.string.delete), R.drawable.ic_fluent_delete_28_regular, () -> new DeleteList(listID).setCallback(new Callback<>() { + @Override - public void onSuccess(Object o) { + public void onSuccess(Void result){ callback.run(); } @@ -1275,6 +1344,10 @@ public class UiUtils { openURL(context, accountID, url, true); } + public static void openURL(Context context, String accountID, String url, Object parentObject) { + openURL(context, accountID, url, !(parentObject instanceof Status || parentObject instanceof Account)); + } + public static void openURL(Context context, String accountID, String url, boolean launchBrowser) { lookupURL(context, accountID, url, (clazz, args) -> { if (clazz == null) { @@ -1644,6 +1717,17 @@ public class UiUtils { return insets; } + public static void applyBottomInsetToFAB(View fab, WindowInsets insets){ + int inset; + if(Build.VERSION.SDK_INT>=29 && insets.getTappableElementInsets().bottom==0 /*&& wantsOverlaySystemNavigation()*/){ + int bottomInset=insets.getSystemWindowInsetBottom(); + inset=bottomInset>0 ? Math.max(V.dp(40), bottomInset) : 0; + }else{ + inset=0; + } + ((ViewGroup.MarginLayoutParams) fab.getLayoutParams()).bottomMargin=V.dp(16)+inset; + } + public static String formatDuration(Context context, int seconds){ if(seconds<3600){ int minutes=seconds/60; @@ -1811,4 +1895,72 @@ public class UiUtils { public static ViewPropertyAnimator opacityOut(View v, float alpha){ return v.animate().alpha(alpha).setDuration(300).setInterpolator(CubicBezierInterpolator.DEFAULT); } + + public static void maybeShowTextCopiedToast(Context context){ + //show toast, android from S_V2 on has built-in popup, as documented in + //https://developer.android.com/develop/ui/views/touch-and-input/copy-paste#duplicate-notifications + if(Build.VERSION.SDK_INT<=Build.VERSION_CODES.S_V2){ + Toast.makeText(context, R.string.text_copied, Toast.LENGTH_SHORT).show(); + } + } + + public static boolean needShowClipboardToast(){ + return Build.VERSION.SDK_INT<=Build.VERSION_CODES.S_V2; + } + + 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; + } + + public static CharSequence fixBulletListInString(Context context, @StringRes int res){ + SpannableStringBuilder msg=new SpannableStringBuilder(context.getText(res)); + BulletSpan[] spans=msg.getSpans(0, msg.length(), BulletSpan.class); + for(BulletSpan span:spans){ + BulletSpan betterSpan; + if(Build.VERSION.SDK_INT oldList=hashtags; hashtags=result.hashtags; diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/viewcontrollers/ComposeLanguageAlertViewController.java b/mastodon/src/main/java/org/joinmastodon/android/ui/viewcontrollers/ComposeLanguageAlertViewController.java index 53016ffa1..bd7073885 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/ui/viewcontrollers/ComposeLanguageAlertViewController.java +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/viewcontrollers/ComposeLanguageAlertViewController.java @@ -115,7 +115,7 @@ public class ComposeLanguageAlertViewController{ int i=0; boolean found=false; for(SpecialLocaleInfo li:specialLocales){ - if(previouslySelected.language != null && previouslySelected.language.equals(li.language)){ + if(null!=li.language&&li.language.equals(previouslySelected.language)){ selectedLocale=li.language; selectedIndex=i; found=true; diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/viewcontrollers/ComposeMediaViewController.java b/mastodon/src/main/java/org/joinmastodon/android/ui/viewcontrollers/ComposeMediaViewController.java index 75c157d9c..7aee0ea29 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/ui/viewcontrollers/ComposeMediaViewController.java +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/viewcontrollers/ComposeMediaViewController.java @@ -124,7 +124,7 @@ public class ComposeMediaViewController{ updateMediaAttachmentsLayout(); } } - + public boolean addMediaAttachment(Uri uri, String description){ if(getMediaAttachmentsCount()==MAX_ATTACHMENTS){ showMediaAttachmentError(fragment.getResources().getQuantityString(R.plurals.cant_add_more_than_x_attachments, MAX_ATTACHMENTS, MAX_ATTACHMENTS)); diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/viewcontrollers/DropdownSubmenuController.java b/mastodon/src/main/java/org/joinmastodon/android/ui/viewcontrollers/DropdownSubmenuController.java new file mode 100644 index 000000000..4cfe3abaf --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/viewcontrollers/DropdownSubmenuController.java @@ -0,0 +1,190 @@ +package org.joinmastodon.android.ui.viewcontrollers; + +import android.graphics.Canvas; +import android.graphics.Paint; +import android.graphics.Rect; +import android.text.TextUtils; +import android.view.View; +import android.view.ViewGroup; +import android.view.accessibility.AccessibilityNodeInfo; +import android.widget.ImageView; +import android.widget.LinearLayout; +import android.widget.TextView; + +import org.joinmastodon.android.R; +import org.joinmastodon.android.ui.BetterItemAnimator; +import org.joinmastodon.android.ui.utils.HideableSingleViewRecyclerAdapter; +import org.joinmastodon.android.ui.utils.UiUtils; + +import java.util.List; +import java.util.function.Consumer; + +import androidx.annotation.DrawableRes; +import androidx.annotation.NonNull; +import androidx.annotation.StringRes; +import androidx.recyclerview.widget.LinearLayoutManager; +import androidx.recyclerview.widget.RecyclerView; +import me.grishka.appkit.utils.BindableViewHolder; +import me.grishka.appkit.utils.MergeRecyclerAdapter; +import me.grishka.appkit.utils.V; +import me.grishka.appkit.views.UsableRecyclerView; + +public abstract class DropdownSubmenuController{ + protected List> items; + protected LinearLayout contentView; + protected UsableRecyclerView list; + protected TextView backItem; + protected final ToolbarDropdownMenuController dropdownController; + protected MergeRecyclerAdapter mergeAdapter; + protected ItemsAdapter itemsAdapter; + + public DropdownSubmenuController(ToolbarDropdownMenuController dropdownController){ + this.dropdownController=dropdownController; + } + + protected abstract CharSequence getBackItemTitle(); + public void onDismiss(){} + + protected void createView(){ + contentView=new LinearLayout(dropdownController.getActivity()); + contentView.setOrientation(LinearLayout.VERTICAL); + CharSequence backTitle=getBackItemTitle(); + if(!TextUtils.isEmpty(backTitle)){ + 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_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(){ + @Override + public void onInitializeAccessibilityNodeInfo(@NonNull View host, @NonNull AccessibilityNodeInfo info){ + super.onInitializeAccessibilityNodeInfo(host, info); + info.setText(info.getText()+". "+host.getResources().getString(R.string.back)); + } + }); + contentView.addView(backItem); + } + list=new UsableRecyclerView(dropdownController.getActivity()); + list.setLayoutManager(new LinearLayoutManager(dropdownController.getActivity())); + itemsAdapter=new ItemsAdapter(); + mergeAdapter=new MergeRecyclerAdapter(); + mergeAdapter.addAdapter(itemsAdapter); + list.setAdapter(mergeAdapter); + list.setPadding(0, backItem!=null ? 0 : V.dp(8), 0, V.dp(8)); + list.setClipToPadding(false); + list.setItemAnimator(new BetterItemAnimator()); + list.addItemDecoration(new RecyclerView.ItemDecoration(){ + private final Paint paint=new Paint(); + { + paint.setStyle(Paint.Style.STROKE); + paint.setStrokeWidth(V.dp(1)); + paint.setColor(UiUtils.getThemeColor(dropdownController.getActivity(), R.attr.colorM3OutlineVariant)); + } + + @Override + public void onDraw(@NonNull Canvas c, @NonNull RecyclerView parent, @NonNull RecyclerView.State state){ + for(int i=0;i{ + public final String title; + public final boolean hasSubmenu; + public final boolean dividerBefore; + public final T parentObject; + public final Consumer> onClick; + + public Item(String title, boolean hasSubmenu, boolean dividerBefore, T parentObject, Consumer> onClick){ + this.title=title; + this.hasSubmenu=hasSubmenu; + this.dividerBefore=dividerBefore; + this.parentObject=parentObject; + this.onClick=onClick; + } + + public Item(String title, boolean hasSubmenu, boolean dividerBefore, Consumer> onClick){ + this(title, hasSubmenu, dividerBefore, null, onClick); + } + + public Item(@StringRes int titleRes, boolean hasSubmenu, boolean dividerBefore, Consumer> onClick){ + this(dropdownController.getActivity().getString(titleRes), hasSubmenu, dividerBefore, null, onClick); + } + + private void performClick(){ + onClick.accept(this); + } + } + + protected class ItemsAdapter extends RecyclerView.Adapter{ + + @NonNull + @Override + public ItemHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType){ + return new ItemHolder(); + } + + @Override + public void onBindViewHolder(@NonNull ItemHolder holder, int position){ + holder.bind(items.get(position)); + } + + @Override + public int getItemCount(){ + return items.size(); + } + } + + private class ItemHolder extends BindableViewHolder> implements UsableRecyclerView.Clickable{ + private final TextView text; + + public ItemHolder(){ + super(dropdownController.getActivity(), R.layout.item_dropdown_menu, list); + text=(TextView) itemView; + } + + @Override + public void onBind(Item item){ + text.setText(item.title); + text.setCompoundDrawablesRelativeWithIntrinsicBounds(0, 0, item.hasSubmenu ? R.drawable.ic_arrow_right_24px : 0, 0); + } + + @Override + public void onClick(){ + item.performClick(); + } + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/viewcontrollers/HomeTimelineHashtagsMenuController.java b/mastodon/src/main/java/org/joinmastodon/android/ui/viewcontrollers/HomeTimelineHashtagsMenuController.java new file mode 100644 index 000000000..74f4698e5 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/viewcontrollers/HomeTimelineHashtagsMenuController.java @@ -0,0 +1,111 @@ +package org.joinmastodon.android.ui.viewcontrollers; + +import android.app.Activity; +import android.os.Bundle; +import android.view.Gravity; +import android.view.ViewGroup; +import android.widget.FrameLayout; +import android.widget.ProgressBar; + +import org.joinmastodon.android.R; +import org.joinmastodon.android.api.requests.tags.GetFollowedTags; +import org.joinmastodon.android.fragments.ManageFollowedHashtagsFragment; +import org.joinmastodon.android.model.Hashtag; +import org.joinmastodon.android.model.HeaderPaginationList; +import org.joinmastodon.android.ui.utils.HideableSingleViewRecyclerAdapter; +import org.joinmastodon.android.ui.utils.UiUtils; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; + +import androidx.recyclerview.widget.RecyclerView; +import me.grishka.appkit.Nav; +import me.grishka.appkit.api.APIRequest; +import me.grishka.appkit.api.Callback; +import me.grishka.appkit.api.ErrorResponse; +import me.grishka.appkit.utils.V; + +public class HomeTimelineHashtagsMenuController extends DropdownSubmenuController{ + private HideableSingleViewRecyclerAdapter largeProgressAdapter; + private HideableSingleViewRecyclerAdapter emptyAdapter; + private APIRequest currentRequest; + + public HomeTimelineHashtagsMenuController(ToolbarDropdownMenuController dropdownController){ + super(dropdownController); + items=new ArrayList<>(); + loadHashtags(); + } + + @Override + protected void createView(){ + super.createView(); + 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); + largeProgressView.setLayoutParams(new RecyclerView.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)); + ProgressBar progress=new ProgressBar(dropdownController.getActivity()); + largeProgressView.addView(progress, new FrameLayout.LayoutParams(V.dp(48), V.dp(48), Gravity.CENTER)); + largeProgressAdapter=new HideableSingleViewRecyclerAdapter(largeProgressView); + mergeAdapter.addAdapter(0, largeProgressAdapter); + emptyAdapter.setVisible(false); + mergeAdapter.addAdapter(0, emptyAdapter); + } + + @Override + protected CharSequence getBackItemTitle(){ + return dropdownController.getActivity().getString(R.string.followed_hashtags); + } + + @Override + public void onDismiss(){ + if(currentRequest!=null){ + currentRequest.cancel(); + currentRequest=null; + } + } + + private void onTagClick(Item item){ + dropdownController.dismiss(); + UiUtils.openHashtagTimeline(dropdownController.getActivity(), dropdownController.getAccountID(), item.parentObject); + } + + private void onManageTagsClick(){ + dropdownController.dismiss(); + Bundle args=new Bundle(); + args.putString("account", dropdownController.getAccountID()); + Nav.go(dropdownController.getActivity(), ManageFollowedHashtagsFragment.class, args); + } + + private void loadHashtags(){ + currentRequest=new GetFollowedTags(null, 200) + .setCallback(new Callback<>(){ + @Override + public void onSuccess(HeaderPaginationList result){ + currentRequest=null; + dropdownController.resizeOnNextFrame(); + largeProgressAdapter.setVisible(false); + ((List) result).sort(Comparator.comparing(tag->tag.name)); + int prevSize=items.size(); + for(Hashtag tag:result){ + items.add(new Item<>("#"+tag.name, false, false, tag, HomeTimelineHashtagsMenuController.this::onTagClick)); + } + items.add(new Item(R.string.manage_hashtags, false, true, i->onManageTagsClick())); + itemsAdapter.notifyItemRangeInserted(prevSize, result.size()+1); + emptyAdapter.setVisible(result.isEmpty()); + } + + @Override + public void onError(ErrorResponse error){ + currentRequest=null; + Activity activity=dropdownController.getActivity(); + if(activity!=null) + error.showToast(activity); + dropdownController.popSubmenuController(); + + } + }) + .exec(dropdownController.getAccountID()); + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/viewcontrollers/HomeTimelineListsMenuController.java b/mastodon/src/main/java/org/joinmastodon/android/ui/viewcontrollers/HomeTimelineListsMenuController.java new file mode 100644 index 000000000..22c78468a --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/viewcontrollers/HomeTimelineListsMenuController.java @@ -0,0 +1,61 @@ +package org.joinmastodon.android.ui.viewcontrollers; + +import android.os.Bundle; + +import org.joinmastodon.android.R; +import org.joinmastodon.android.fragments.CreateListFragment; +import org.joinmastodon.android.fragments.ManageListsFragment; +import org.joinmastodon.android.model.FollowList; +import org.joinmastodon.android.ui.utils.HideableSingleViewRecyclerAdapter; + +import java.util.ArrayList; +import java.util.List; + +import me.grishka.appkit.Nav; + +public class HomeTimelineListsMenuController extends DropdownSubmenuController{ + private final List lists; + private final HomeTimelineMenuController.Callback callback; + private HideableSingleViewRecyclerAdapter emptyAdapter; + + public HomeTimelineListsMenuController(ToolbarDropdownMenuController dropdownController, HomeTimelineMenuController.Callback callback){ + super(dropdownController); + this.lists=new ArrayList<>(callback.getLists()); + this.callback=callback; + items=new ArrayList<>(); + for(FollowList l:lists){ + items.add(new Item<>(l.title, false, false, l, this::onListSelected)); + } + items.add(new Item(dropdownController.getActivity().getString(R.string.create_list), false, true, i->{ + dropdownController.dismiss(); + Bundle args=new Bundle(); + args.putString("account", dropdownController.getAccountID()); + Nav.go(dropdownController.getActivity(), CreateListFragment.class, args); + })); + items.add(new Item(dropdownController.getActivity().getString(R.string.manage_lists), false, false, i->{ + dropdownController.dismiss(); + Bundle args=new Bundle(); + args.putString("account", dropdownController.getAccountID()); + Nav.go(dropdownController.getActivity(), ManageListsFragment.class, args); + })); + } + + @Override + protected CharSequence getBackItemTitle(){ + return dropdownController.getActivity().getString(R.string.lists); + } + + @Override + protected void createView(){ + super.createView(); + emptyAdapter=createEmptyView(R.drawable.ic_list_alt_24px, R.string.no_lists_title, R.string.no_lists_subtitle); + if(lists.isEmpty()){ + mergeAdapter.addAdapter(0, emptyAdapter); + } + } + + private void onListSelected(Item item){ + callback.onListSelected(item.parentObject); + dropdownController.dismiss(); + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/viewcontrollers/HomeTimelineMenuController.java b/mastodon/src/main/java/org/joinmastodon/android/ui/viewcontrollers/HomeTimelineMenuController.java new file mode 100644 index 000000000..2391d5b7b --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/viewcontrollers/HomeTimelineMenuController.java @@ -0,0 +1,39 @@ +package org.joinmastodon.android.ui.viewcontrollers; + +import org.joinmastodon.android.R; +import org.joinmastodon.android.model.FollowList; + +import java.util.List; + +public class HomeTimelineMenuController extends DropdownSubmenuController{ + private Callback callback; + + public HomeTimelineMenuController(ToolbarDropdownMenuController dropdownController, Callback callback){ + super(dropdownController); + this.callback=callback; + items=List.of( + new Item(R.string.timeline_following, false, false, i->{ + callback.onFollowingSelected(); + dropdownController.dismiss(); + }), + new Item(R.string.local_timeline, false, false, i->{ + callback.onLocalSelected(); + dropdownController.dismiss(); + }), + new Item(R.string.lists, true, true, i->dropdownController.pushSubmenuController(new HomeTimelineListsMenuController(dropdownController, callback))), + new Item(R.string.followed_hashtags, true, false, i->dropdownController.pushSubmenuController(new HomeTimelineHashtagsMenuController(dropdownController))) + ); + } + + @Override + protected CharSequence getBackItemTitle(){ + return null; + } + + public interface Callback{ + void onFollowingSelected(); + void onLocalSelected(); + List getLists(); + void onListSelected(FollowList list); + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/viewcontrollers/ToolbarDropdownMenuController.java b/mastodon/src/main/java/org/joinmastodon/android/ui/viewcontrollers/ToolbarDropdownMenuController.java new file mode 100644 index 000000000..520ef51ef --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/viewcontrollers/ToolbarDropdownMenuController.java @@ -0,0 +1,270 @@ +package org.joinmastodon.android.ui.viewcontrollers; + +import android.animation.Animator; +import android.animation.AnimatorListenerAdapter; +import android.animation.AnimatorSet; +import android.animation.ObjectAnimator; +import android.animation.ValueAnimator; +import android.app.Activity; +import android.content.Context; +import android.content.res.Resources; +import android.graphics.PixelFormat; +import android.graphics.Rect; +import android.view.Gravity; +import android.view.KeyEvent; +import android.view.MotionEvent; +import android.view.View; +import android.view.ViewGroup; +import android.view.ViewTreeObserver; +import android.view.WindowManager; +import android.widget.FrameLayout; +import android.widget.Toolbar; + +import org.joinmastodon.android.R; +import org.joinmastodon.android.ui.OutlineProviders; + +import java.util.ArrayList; +import java.util.List; + +import androidx.annotation.NonNull; +import me.grishka.appkit.utils.CubicBezierInterpolator; +import me.grishka.appkit.utils.V; + +public class ToolbarDropdownMenuController{ + private final HostFragment fragment; + private FrameLayout windowView; + private FrameLayout menuContainer; + private boolean dismissing; + private List controllerStack=new ArrayList<>(); + private Animator currentTransition; + + public ToolbarDropdownMenuController(HostFragment fragment){ + this.fragment=fragment; + } + + public void show(DropdownSubmenuController initialSubmenu){ + if(windowView!=null) + return; + + menuContainer=new FrameLayout(fragment.getActivity()); + menuContainer.setBackgroundResource(R.drawable.bg_m3_surface2); + menuContainer.setOutlineProvider(OutlineProviders.roundedRect(4)); + menuContainer.setClipToOutline(true); + menuContainer.setElevation(V.dp(6)); + View menuView=initialSubmenu.getView(); + menuView.setVisibility(View.VISIBLE); + menuContainer.addView(menuView, new FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)); + + windowView=new WindowView(fragment.getActivity()); + int pad=V.dp(16); + windowView.setPadding(pad, fragment.getToolbar().getHeight(), pad, pad); + windowView.setClipToPadding(false); + windowView.addView(menuContainer, new FrameLayout.LayoutParams(V.dp(200), ViewGroup.LayoutParams.WRAP_CONTENT, Gravity.TOP | Gravity.START)); + + WindowManager.LayoutParams wlp=new WindowManager.LayoutParams(WindowManager.LayoutParams.TYPE_APPLICATION_PANEL); + wlp.format=PixelFormat.TRANSLUCENT; + wlp.token=fragment.getActivity().getWindow().getDecorView().getWindowToken(); + wlp.width=wlp.height=ViewGroup.LayoutParams.MATCH_PARENT; + wlp.flags=WindowManager.LayoutParams.FLAG_LAYOUT_ATTACHED_IN_DECOR; + wlp.setTitle(fragment.getActivity().getString(R.string.dropdown_menu)); + fragment.getActivity().getWindowManager().addView(windowView, wlp); + + menuContainer.setPivotX(V.dp(100)); + menuContainer.setPivotY(0); + menuContainer.setScaleX(.8f); + menuContainer.setScaleY(.8f); + menuContainer.setAlpha(0f); + menuContainer.animate() + .scaleX(1f) + .scaleY(1f) + .alpha(1f) + .setInterpolator(CubicBezierInterpolator.DEFAULT) + .setDuration(150) + .withLayer() + .start(); + controllerStack.add(initialSubmenu); + } + + public void dismiss(){ + if(windowView==null || dismissing) + return; + dismissing=true; + fragment.onDropdownWillDismiss(); + menuContainer.animate() + .scaleX(.8f) + .scaleY(.8f) + .alpha(0f) + .setInterpolator(CubicBezierInterpolator.DEFAULT) + .setDuration(150) + .withLayer() + .withEndAction(()->{ + controllerStack.clear(); + fragment.getActivity().getWindowManager().removeView(windowView); + menuContainer.removeAllViews(); + dismissing=false; + windowView=null; + menuContainer=null; + fragment.onDropdownDismissed(); + }) + .start(); + } + + public void pushSubmenuController(DropdownSubmenuController controller){ + View prevView=menuContainer.getChildAt(menuContainer.getChildCount()-1); + View newView=controller.getView(); + newView.setVisibility(View.VISIBLE); + menuContainer.addView(newView, new FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)); + controllerStack.add(controller); + animateTransition(prevView, newView, true); + } + + public void popSubmenuController(){ + if(menuContainer.getChildCount()<=1) + throw new IllegalStateException(); + DropdownSubmenuController controller=controllerStack.remove(controllerStack.size()-1); + controller.onDismiss(); + View top=menuContainer.getChildAt(menuContainer.getChildCount()-1); + View prev=menuContainer.getChildAt(menuContainer.getChildCount()-2); + prev.setVisibility(View.VISIBLE); + animateTransition(prev, top, false); + } + + private void animateTransition(View bottomView, View topView, boolean adding){ + if(currentTransition!=null) + currentTransition.cancel(); + int origBottom=menuContainer.getBottom(); + menuContainer.getViewTreeObserver().addOnPreDrawListener(new ViewTreeObserver.OnPreDrawListener(){ + private final Rect tmpRect=new Rect(); + + @Override + public boolean onPreDraw(){ + menuContainer.getViewTreeObserver().removeOnPreDrawListener(this); + + AnimatorSet set=new AnimatorSet(); + ObjectAnimator slideIn; + set.playTogether( + ObjectAnimator.ofInt(menuContainer, "bottom", origBottom, menuContainer.getTop()+(adding ? topView : bottomView).getHeight()), + slideIn=ObjectAnimator.ofFloat(topView, View.TRANSLATION_X, adding ? menuContainer.getWidth() : 0, adding ? 0 : menuContainer.getWidth()), + ObjectAnimator.ofFloat(bottomView, View.TRANSLATION_X, adding ? 0 : -menuContainer.getWidth()/4f, adding ? -menuContainer.getWidth()/4f : 0), + ObjectAnimator.ofFloat(bottomView, View.ALPHA, adding ? 1f : 0f, adding ? 0f : 1f) + ); + set.setDuration(300); + set.setInterpolator(CubicBezierInterpolator.DEFAULT); + set.addListener(new AnimatorListenerAdapter(){ + @Override + public void onAnimationEnd(Animator animation){ + bottomView.setClipBounds(null); + bottomView.setTranslationX(0); + bottomView.setAlpha(1f); + topView.setTranslationX(0); + topView.setAlpha(1f); + if(adding){ + bottomView.setVisibility(View.GONE); + }else{ + menuContainer.removeView(topView); + } + currentTransition=null; + } + }); + slideIn.addUpdateListener(animation->{ + tmpRect.set(0, 0, Math.round(topView.getX()-bottomView.getX()), bottomView.getHeight()); + bottomView.setClipBounds(tmpRect); + }); + currentTransition=set; + set.start(); + + return true; + } + }); + } + + public void resizeOnNextFrame(){ + if(currentTransition!=null) + currentTransition.cancel(); + if(windowView==null) + return; + int origBottom=menuContainer.getBottom(); + menuContainer.getViewTreeObserver().addOnPreDrawListener(new ViewTreeObserver.OnPreDrawListener(){ + @Override + public boolean onPreDraw(){ + menuContainer.getViewTreeObserver().removeOnPreDrawListener(this); + + ObjectAnimator anim=ObjectAnimator.ofInt(menuContainer, "bottom", origBottom, menuContainer.getBottom()); + anim.setDuration(300); + anim.setInterpolator(CubicBezierInterpolator.DEFAULT); + anim.addListener(new AnimatorListenerAdapter(){ + @Override + public void onAnimationEnd(Animator animation){ + currentTransition=null; + } + }); + currentTransition=anim; + anim.start(); + + return true; + } + }); + } + + Activity getActivity(){ + return fragment.getActivity(); + } + + String getAccountID(){ + return fragment.getAccountID(); + } + + private class WindowView extends FrameLayout{ + private final Rect tmpRect=new Rect(); + public WindowView(@NonNull Context context){ + super(context); + } + + @Override + public boolean onTouchEvent(MotionEvent ev){ + for(int i=0;i1) + popSubmenuController(); + else + dismiss(); + } + return true; + } + return super.dispatchKeyEvent(event); + } + } + + public interface HostFragment{ + // Fragment methods + Activity getActivity(); + Resources getResources(); + Toolbar getToolbar(); + String getAccountID(); + + // Callbacks + void onDropdownWillDismiss(); + void onDropdownDismissed(); + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/viewholders/AccountViewHolder.java b/mastodon/src/main/java/org/joinmastodon/android/ui/viewholders/AccountViewHolder.java index caa89f797..a431bf2c9 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/ui/viewholders/AccountViewHolder.java +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/viewholders/AccountViewHolder.java @@ -16,6 +16,7 @@ import android.view.View; import android.view.ViewGroup; import android.widget.CheckBox; import android.widget.FrameLayout; +import android.widget.ImageButton; import android.widget.ImageView; import android.widget.PopupMenu; import android.widget.ProgressBar; @@ -26,7 +27,7 @@ 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; import org.joinmastodon.android.model.Account; @@ -43,7 +44,9 @@ import java.util.HashMap; import java.util.Objects; import java.util.Optional; import java.util.function.Consumer; +import java.util.function.Predicate; +import androidx.annotation.LayoutRes; import me.grishka.appkit.Nav; import me.grishka.appkit.api.Callback; import me.grishka.appkit.api.ErrorResponse; @@ -53,7 +56,7 @@ import me.grishka.appkit.views.UsableRecyclerView; public class AccountViewHolder extends BindableViewHolder implements ImageLoaderViewHolder, UsableRecyclerView.Clickable, UsableRecyclerView.LongClickable{ private final TextView name, username, followers, pronouns, bio; - private final ImageView avatar; + public final ImageView avatar; private final FrameLayout accessory; private final ProgressBarButton button; private final PopupMenu contextMenu; @@ -62,18 +65,25 @@ public class AccountViewHolder extends BindableViewHolder impl private final CheckableRelativeLayout view; private final View checkbox; private final ProgressBar actionProgress; + private final ImageButton menuButton; private final String accountID; private final Fragment fragment; private final HashMap relationships; private Consumer onClick; + private Predicate onLongClick; + private Consumer onCustomMenuItemSelected; private AccessoryType accessoryType; private boolean showBio; private boolean checked; public AccountViewHolder(Fragment fragment, ViewGroup list, HashMap relationships){ - super(fragment.getActivity(), R.layout.item_account_list, list); + this(fragment, list, relationships, R.layout.item_account_list); + } + + public AccountViewHolder(Fragment fragment, ViewGroup list, HashMap relationships, @LayoutRes int layout){ + super(fragment.getActivity(), layout, list); this.fragment=fragment; this.accountID=Objects.requireNonNull(fragment.getArguments().getString("account")); this.relationships=relationships; @@ -90,6 +100,7 @@ public class AccountViewHolder extends BindableViewHolder impl bio=findViewById(R.id.bio); checkbox=findViewById(R.id.checkbox); actionProgress=findViewById(R.id.action_progress); + menuButton=findViewById(R.id.options_btn); avatar.setOutlineProvider(OutlineProviders.roundedRect(10)); avatar.setClipToOutline(true); @@ -100,6 +111,7 @@ public class AccountViewHolder extends BindableViewHolder impl 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); @@ -209,6 +221,10 @@ public class AccountViewHolder extends BindableViewHolder impl @Override public boolean onLongClick(float x, float y){ + if(onLongClick!=null && onLongClick.test(this)) + return true; + if(accessoryType==AccessoryType.MENU || !prepareMenu()) + return false; if(relationships==null) return false; Relationship relationship=relationships.get(item.account.id); @@ -247,7 +263,6 @@ public class AccountViewHolder extends BindableViewHolder impl menuAnchor.setTranslationX(x); menuAnchor.setTranslationY(y); contextMenu.show(); - return true; } @@ -262,7 +277,7 @@ public class AccountViewHolder extends BindableViewHolder impl }); } - private void setActionProgressVisible(boolean visible){ + public void setActionProgressVisible(boolean visible){ if(visible) actionProgress.setIndeterminateTintList(button.getTextColors()); button.setTextVisible(!visible); @@ -318,11 +333,12 @@ public class AccountViewHolder extends BindableViewHolder impl .wrapProgress(fragment.getActivity(), R.string.loading, false) .exec(accountID); }else if(id==R.id.manage_user_lists){ - final Bundle args=new Bundle(); + Bundle args=new Bundle(); args.putString("account", accountID); - args.putString("profileAccount", account.id); - args.putString("profileDisplayUsername", account.getDisplayUsername()); - Nav.go(fragment.getActivity(), ListsFragment.class, args); + args.putParcelable("targetAccount", Parcels.wrap(account)); + Nav.go(fragment.getActivity(), AddAccountToListsFragment.class, args); + }else if(onCustomMenuItemSelected!=null){ + onCustomMenuItemSelected.accept(item); } return true; } @@ -336,6 +352,14 @@ public class AccountViewHolder extends BindableViewHolder impl onClick=listener; } + public void setOnLongClickListener(Predicate onLongClick){ + this.onLongClick=onLongClick; + } + + public void setOnCustomMenuItemSelectedListener(Consumer onCustomMenuItemSelected){ + this.onCustomMenuItemSelected=onCustomMenuItemSelected; + } + public void setStyle(AccessoryType accessoryType, boolean showBio){ if(accessoryType!=this.accessoryType){ this.accessoryType=accessoryType; @@ -343,20 +367,29 @@ public class AccountViewHolder extends BindableViewHolder impl case NONE -> { button.setVisibility(View.GONE); checkbox.setVisibility(View.GONE); + menuButton.setVisibility(View.GONE); } case CHECKBOX -> { button.setVisibility(View.GONE); checkbox.setVisibility(View.VISIBLE); + menuButton.setVisibility(View.GONE); checkbox.setBackground(new CheckBox(checkbox.getContext()).getButtonDrawable()); } case RADIOBUTTON -> { button.setVisibility(View.GONE); checkbox.setVisibility(View.VISIBLE); + menuButton.setVisibility(View.GONE); checkbox.setBackground(new RadioButton(checkbox.getContext()).getButtonDrawable()); } - case BUTTON -> { + case BUTTON, CUSTOM_BUTTON -> { button.setVisibility(View.VISIBLE); checkbox.setVisibility(View.GONE); + menuButton.setVisibility(View.GONE); + } + case MENU -> { + button.setVisibility(View.GONE); + checkbox.setVisibility(View.GONE); + menuButton.setVisibility(View.VISIBLE); } } view.setCheckable(accessoryType==AccessoryType.CHECKBOX || accessoryType==AccessoryType.RADIOBUTTON); @@ -365,15 +398,68 @@ public class AccountViewHolder extends BindableViewHolder impl bio.setVisibility(showBio ? View.VISIBLE : View.GONE); } + private boolean prepareMenu(){ + 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.share).setTitle(R.string.share_user); + menu.findItem(R.id.mute).setTitle(fragment.getString(relationship.muting ? R.string.unmute_user : R.string.mute_user, account.getDisplayUsername())); + menu.findItem(R.id.block).setTitle(fragment.getString(relationship.blocking ? R.string.unblock_user : R.string.block_user, account.getDisplayUsername())); + menu.findItem(R.id.report).setTitle(fragment.getString(R.string.report_user, account.getDisplayUsername())); + 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.getDisplayUsername())); + 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); + } + menu.findItem(R.id.manage_user_lists).setVisible(relationship.following); + return true; + } + + private void showMenuFromButton(){ + if(!prepareMenu()) + return; + int[] xy={0, 0}; + itemView.getLocationInWindow(xy); + int x=xy[0], y=xy[1]; + menuButton.getLocationInWindow(xy); + menuAnchor.setTranslationX(xy[0]-x+menuButton.getWidth()/2f); + menuAnchor.setTranslationY(xy[1]-y+menuButton.getHeight()); + contextMenu.show(); + } + public void setChecked(boolean checked){ this.checked=checked; view.setChecked(checked); } + public PopupMenu getContextMenu(){ + return contextMenu; + } + + public ProgressBarButton getButton(){ + return button; + } + public enum AccessoryType{ NONE, BUTTON, CHECKBOX, - RADIOBUTTON + RADIOBUTTON, + MENU, + CUSTOM_BUTTON } } diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/viewholders/AvatarPileListItemViewHolder.java b/mastodon/src/main/java/org/joinmastodon/android/ui/viewholders/AvatarPileListItemViewHolder.java new file mode 100644 index 000000000..1590f7df4 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/viewholders/AvatarPileListItemViewHolder.java @@ -0,0 +1,42 @@ +package org.joinmastodon.android.ui.viewholders; + +import android.content.Context; +import android.graphics.drawable.Drawable; +import android.view.ViewGroup; +import android.widget.LinearLayout; + +import org.joinmastodon.android.R; +import org.joinmastodon.android.model.viewmodel.AvatarPileListItem; +import org.joinmastodon.android.ui.views.AvatarPileView; + +import me.grishka.appkit.imageloader.ImageLoaderViewHolder; +import me.grishka.appkit.utils.V; + +public class AvatarPileListItemViewHolder extends ListItemViewHolder> implements ImageLoaderViewHolder{ + private final AvatarPileView pile; + + public AvatarPileListItemViewHolder(Context context, ViewGroup parent){ + super(context, R.layout.item_generic_list, parent); + pile=new AvatarPileView(context); + LinearLayout.LayoutParams lp=new LinearLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.MATCH_PARENT); + lp.topMargin=lp.bottomMargin=V.dp(-8); + view.addView(pile, lp); + view.setClipToPadding(false); + } + + @Override + public void onBind(AvatarPileListItem item){ + super.onBind(item); + pile.setVisibleAvatarCount(item.avatars.size()); + } + + @Override + public void setImage(int index, Drawable image){ + pile.avatars[index].setImageDrawable(image); + } + + @Override + public void clearImage(int index){ + pile.avatars[index].setImageResource(R.drawable.image_placeholder); + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/viewholders/OptionsListItemViewHolder.java b/mastodon/src/main/java/org/joinmastodon/android/ui/viewholders/OptionsListItemViewHolder.java new file mode 100644 index 000000000..30a326142 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/viewholders/OptionsListItemViewHolder.java @@ -0,0 +1,33 @@ +package org.joinmastodon.android.ui.viewholders; + +import android.content.Context; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ImageButton; +import android.widget.PopupMenu; + +import org.joinmastodon.android.R; +import org.joinmastodon.android.model.viewmodel.ListItemWithOptionsMenu; + +public class OptionsListItemViewHolder extends ListItemViewHolder>{ + private final PopupMenu menu; + private final ImageButton menuBtn; + + public OptionsListItemViewHolder(Context context, ViewGroup parent){ + super(context, R.layout.item_generic_list_options, parent); + menuBtn=findViewById(R.id.options_btn); + menu=new PopupMenu(context, menuBtn); + menuBtn.setOnClickListener(this::onMenuBtnClick); + + menu.setOnMenuItemClickListener(menuItem->{ + item.performItemSelected(menuItem); + return true; + }); + } + + private void onMenuBtnClick(View v){ + menu.getMenu().clear(); + item.performConfigureMenu(menu.getMenu()); + menu.show(); + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/viewholders/SwitchListItemViewHolder.java b/mastodon/src/main/java/org/joinmastodon/android/ui/viewholders/SwitchListItemViewHolder.java index 85cefd009..6dd042a2e 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/ui/viewholders/SwitchListItemViewHolder.java +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/viewholders/SwitchListItemViewHolder.java @@ -2,10 +2,13 @@ package org.joinmastodon.android.ui.viewholders; import android.content.Context; import android.view.Gravity; +import android.view.View; import android.view.ViewGroup; import android.widget.LinearLayout; +import org.joinmastodon.android.R; import org.joinmastodon.android.model.viewmodel.CheckableListItem; +import org.joinmastodon.android.ui.utils.UiUtils; import org.joinmastodon.android.ui.views.M3Switch; import me.grishka.appkit.utils.V; @@ -14,8 +17,17 @@ public class SwitchListItemViewHolder extends CheckableListItemViewHolder{ private final M3Switch sw; private boolean ignoreListener; - public SwitchListItemViewHolder(Context context, ViewGroup parent){ + public SwitchListItemViewHolder(Context context, ViewGroup parent, boolean separated){ super(context, parent); + if(separated){ + View separator=new View(context); + separator.setBackgroundColor(UiUtils.getThemeColor(context, R.attr.colorM3OutlineVariant)); + LinearLayout.LayoutParams lp=new LinearLayout.LayoutParams(V.dp(1), V.dp(32)); + lp.gravity=Gravity.TOP; + lp.setMarginStart(V.dp(16)); + lp.setMarginEnd(V.dp(-1)); + checkableLayout.addView(separator, lp); + } sw=new M3Switch(context); LinearLayout.LayoutParams lp=new LinearLayout.LayoutParams(V.dp(52), V.dp(32)); lp.gravity=Gravity.TOP; diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/views/AvatarPileView.java b/mastodon/src/main/java/org/joinmastodon/android/ui/views/AvatarPileView.java new file mode 100644 index 000000000..bdb050f0d --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/views/AvatarPileView.java @@ -0,0 +1,81 @@ +package org.joinmastodon.android.ui.views; + +import android.content.Context; +import android.graphics.Canvas; +import android.graphics.Paint; +import android.graphics.PorterDuff; +import android.graphics.PorterDuffXfermode; +import android.graphics.RectF; +import android.util.AttributeSet; +import android.view.Gravity; +import android.view.View; +import android.widget.ImageView; +import android.widget.LinearLayout; + +import org.joinmastodon.android.R; +import org.joinmastodon.android.ui.OutlineProviders; + +import androidx.annotation.Nullable; +import me.grishka.appkit.utils.CustomViewHelper; + +public class AvatarPileView extends LinearLayout implements CustomViewHelper{ + public final ImageView[] avatars=new ImageView[3]; + private final Paint borderPaint=new Paint(Paint.ANTI_ALIAS_FLAG); + private final RectF tmpRect=new RectF(); + + public AvatarPileView(Context context){ + super(context); + init(); + } + + public AvatarPileView(Context context, @Nullable AttributeSet attrs){ + super(context, attrs); + init(); + } + + public AvatarPileView(Context context, @Nullable AttributeSet attrs, int defStyleAttr){ + super(context, attrs, defStyleAttr); + init(); + } + + private void init(){ + setLayerType(LAYER_TYPE_HARDWARE, null); + setPaddingRelative(dp(16), 0, 0, 0); + setClipToPadding(false); + for(int i=0;idst.height()){ + path.computeBounds(src, false); + matrix.setRotate(90, src.centerX(), src.centerY()); + matrix.postScale(-1f, 1f, src.centerX(), src.centerY()); + path.transform(matrix); + isReversed=true; + } + PathMeasure pm=new PathMeasure(path, false); + float[] pos=new float[2], tan=new float[2]; + pm.getPosTan(isReversed ? pm.getLength() : 0, pos, null); + src.left=pos[0]; + src.bottom=pos[1]; + pm.getPosTan(isReversed ? 0 : pm.getLength(), pos, null); + src.right=pos[0]; + src.top=pos[1]; + + matrix.setRectToRect(src, dst, Matrix.ScaleToFit.FILL); + if(startingPointX>endingPointX) + matrix.postScale(-1f, 1f, dst.centerX(), dst.centerY()); + if(startingPointY0 && getChildAt(0) instanceof EditText et){ - edit=et; + if(getChildCount()>0){ + firstChild=getChildAt(0); + if(firstChild instanceof EditText et) + edit=et; }else{ - throw new IllegalStateException("First child must be an EditText"); + throw new IllegalStateException("Must contain at least one child view"); } label=new TextView(getContext()); label.setTextSize(TypedValue.COMPLEX_UNIT_PX, labelTextSize); // label.setTextColor(labelColors==null ? edit.getHintTextColors() : labelColors); - origHintColors=edit.getHintTextColors(); - label.setText(edit.getHint()); + if(edit!=null){ + origHintColors=edit.getHintTextColors(); + label.setText(edit.getHint()); + } label.setSingleLine(); label.setPivotX(0f); label.setPivotY(0f); label.setImportantForAccessibility(IMPORTANT_FOR_ACCESSIBILITY_NO); LayoutParams lp=new LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT, Gravity.START | Gravity.TOP); - lp.setMarginStart(edit.getPaddingStart()+((LayoutParams)edit.getLayoutParams()).getMarginStart()); + lp.setMarginStart(firstChild.getPaddingStart()+((LayoutParams)firstChild.getLayoutParams()).getMarginStart()); addView(label, lp); - hintVisible=edit.getText().length()==0; + hintVisible=edit!=null && edit.getText().length()==0; if(hintVisible) label.setAlpha(0f); + else + animProgress=1; - edit.addTextChangedListener(new SimpleTextWatcher(this::onTextChanged)); + if(edit!=null) + edit.addTextChangedListener(new SimpleTextWatcher(this::onTextChanged)); errorView=new LinkedTextView(getContext()); errorView.setTextAppearance(R.style.m3_body_small); @@ -110,6 +119,18 @@ public class FloatingHintEditTextLayout extends FrameLayout implements CustomVie label.setText(edit.getHint()); } + public void setHint(CharSequence hint){ + label.setText(hint); + } + + public void setHint(@StringRes int hint){ + label.setText(hint); + } + + public TextView getLabel(){ + return label; + } + private void onTextChanged(Editable text){ if(errorState){ errorView.setVisibility(View.GONE); @@ -244,7 +265,7 @@ public class FloatingHintEditTextLayout extends FrameLayout implements CustomVie protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec){ if(errorView.getVisibility()!=GONE){ int width=MeasureSpec.getSize(widthMeasureSpec)-getPaddingLeft()-getPaddingRight(); - LayoutParams editLP=(LayoutParams) edit.getLayoutParams(); + LayoutParams editLP=(LayoutParams) firstChild.getLayoutParams(); width-=editLP.leftMargin+editLP.rightMargin; errorView.measure(width | MeasureSpec.EXACTLY, MeasureSpec.UNSPECIFIED); LayoutParams lp=(LayoutParams) errorView.getLayoutParams(); @@ -254,7 +275,7 @@ public class FloatingHintEditTextLayout extends FrameLayout implements CustomVie lp.leftMargin=editLP.leftMargin; editLP.bottomMargin=errorView.getMeasuredHeight(); }else{ - LayoutParams editLP=(LayoutParams) edit.getLayoutParams(); + LayoutParams editLP=(LayoutParams) firstChild.getLayoutParams(); editLP.bottomMargin=0; } super.onMeasure(widthMeasureSpec, heightMeasureSpec); @@ -355,7 +376,7 @@ public class FloatingHintEditTextLayout extends FrameLayout implements CustomVie protected void onBoundsChange(@NonNull Rect bounds){ super.onBoundsChange(bounds); int offset=dp(12); - wrapped.setBounds(edit.getLeft()-offset, edit.getTop()-offset, edit.getRight()+offset, edit.getBottom()+offset); + wrapped.setBounds(firstChild.getLeft()-offset, firstChild.getTop()-offset, firstChild.getRight()+offset, firstChild.getBottom()+offset); } } } diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/views/ListEditor.java b/mastodon/src/main/java/org/joinmastodon/android/ui/views/ListEditor.java index 1ebe466d2..de4454c36 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/ui/views/ListEditor.java +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/views/ListEditor.java @@ -15,10 +15,10 @@ import androidx.annotation.NonNull; import androidx.annotation.Nullable; import org.joinmastodon.android.R; -import org.joinmastodon.android.model.ListTimeline; +import org.joinmastodon.android.model.FollowList; public class ListEditor extends LinearLayout { - private ListTimeline.RepliesPolicy policy = null; + private FollowList.RepliesPolicy policy = null; private final TextInputFrameLayout input; private final Button button; private final Switch exclusiveSwitch; @@ -42,10 +42,10 @@ public class ListEditor extends LinearLayout { findViewById(R.id.exclusive) .setOnClickListener(v -> exclusiveSwitch.setChecked(!exclusiveSwitch.isChecked())); - setRepliesPolicy(ListTimeline.RepliesPolicy.LIST); + setRepliesPolicy(FollowList.RepliesPolicy.LIST); } - public void applyList(String title, boolean exclusive, @Nullable ListTimeline.RepliesPolicy policy) { + public void applyList(String title, boolean exclusive, @Nullable FollowList.RepliesPolicy policy) { input.getEditText().setText(title); exclusiveSwitch.setChecked(exclusive); if (policy != null) setRepliesPolicy(policy); @@ -55,7 +55,7 @@ public class ListEditor extends LinearLayout { return input.getEditText().getText().toString(); } - public ListTimeline.RepliesPolicy getRepliesPolicy() { + public FollowList.RepliesPolicy getRepliesPolicy() { return policy; } @@ -63,7 +63,7 @@ public class ListEditor extends LinearLayout { return exclusiveSwitch.isChecked(); } - public void setRepliesPolicy(@NonNull ListTimeline.RepliesPolicy policy) { + public void setRepliesPolicy(@NonNull FollowList.RepliesPolicy policy) { this.policy = policy; switch (policy) { case FOLLOWED -> button.setText(R.string.sk_list_replies_policy_followed); @@ -74,11 +74,11 @@ public class ListEditor extends LinearLayout { private boolean onMenuItemClick(MenuItem i) { if (i.getItemId() == R.id.reply_policy_none) { - setRepliesPolicy(ListTimeline.RepliesPolicy.NONE); + setRepliesPolicy(FollowList.RepliesPolicy.NONE); } else if (i.getItemId() == R.id.reply_policy_followed) { - setRepliesPolicy(ListTimeline.RepliesPolicy.FOLLOWED); + setRepliesPolicy(FollowList.RepliesPolicy.FOLLOWED); } else if (i.getItemId() == R.id.reply_policy_list) { - setRepliesPolicy(ListTimeline.RepliesPolicy.LIST); + setRepliesPolicy(FollowList.RepliesPolicy.LIST); } return true; } diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/views/ProgressBarButton.java b/mastodon/src/main/java/org/joinmastodon/android/ui/views/ProgressBarButton.java index ac0e4c9da..ae9497bdc 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/ui/views/ProgressBarButton.java +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/views/ProgressBarButton.java @@ -1,23 +1,42 @@ 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){ - super(context); + this(context, null); } public ProgressBarButton(Context context, AttributeSet attrs){ - super(context, attrs); + this(context, attrs, 0); } - public ProgressBarButton(Context context, AttributeSet attrs, int defStyleAttr){ - super(context, attrs, defStyleAttr); + 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 void setTextVisible(boolean textVisible){ @@ -29,6 +48,19 @@ 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){ diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/views/ReorderableLinearLayout.java b/mastodon/src/main/java/org/joinmastodon/android/ui/views/ReorderableLinearLayout.java index c49bf56b1..c8d880f5a 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/ui/views/ReorderableLinearLayout.java +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/views/ReorderableLinearLayout.java @@ -170,6 +170,7 @@ public class ReorderableLinearLayout extends LinearLayout implements CustomViewH else bottomSibling=null; dragListener.onSwapItems(prevIndex, index); + final View draggedView=this.draggedView; draggedView.getViewTreeObserver().addOnPreDrawListener(new ViewTreeObserver.OnPreDrawListener(){ @Override public boolean onPreDraw(){ diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/views/RippleAnimationTextView.java b/mastodon/src/main/java/org/joinmastodon/android/ui/views/RippleAnimationTextView.java new file mode 100644 index 000000000..b44db321a --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/views/RippleAnimationTextView.java @@ -0,0 +1,170 @@ +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{ + 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--); + } + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/views/WrappingLinearLayout.java b/mastodon/src/main/java/org/joinmastodon/android/ui/views/WrappingLinearLayout.java new file mode 100644 index 000000000..2b8352082 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/views/WrappingLinearLayout.java @@ -0,0 +1,128 @@ +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.view.ViewGroup; + +import org.joinmastodon.android.R; + +import java.util.ArrayList; + +/** + * Something like a horizontal LinearLayout, but wraps child views onto a new line if they don't fit + */ +public class WrappingLinearLayout extends ViewGroup{ + private int verticalGap, horizontalGap; + private ArrayList rowHeights=new ArrayList<>(); + + public WrappingLinearLayout(Context context){ + this(context, null); + } + + public WrappingLinearLayout(Context context, AttributeSet attrs){ + this(context, attrs, 0); + } + + public WrappingLinearLayout(Context context, AttributeSet attrs, int defStyle){ + super(context, attrs, defStyle); + TypedArray ta=context.obtainStyledAttributes(attrs, R.styleable.WrappingLinearLayout); + verticalGap=ta.getDimensionPixelOffset(R.styleable.WrappingLinearLayout_android_verticalGap, 0); + horizontalGap=ta.getDimensionPixelOffset(R.styleable.WrappingLinearLayout_android_horizontalGap, 0); + ta.recycle(); + } + + @Override + protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec){ + int w=MeasureSpec.getSize(widthMeasureSpec)-getPaddingLeft()-getPaddingRight(); + int heightUsed=0, widthRemain=w, currentRowHeight=0; + rowHeights.clear(); + for(int i=0;iwidthRemain){ + // Doesn't fit into the current row. Start a new one. + heightUsed+=currentRowHeight+verticalGap; + rowHeights.add(currentRowHeight); + currentRowHeight=child.getMeasuredHeight()+verticalMargins; + widthRemain=w; + }else{ + // Does fit. Advance horizontally. + if(widthRemain=endPadding){ + xOffset+=childW+horizontalGap; + if(child.getLayoutParams() instanceof MarginLayoutParams mlp){ + xOffset+=mlp.leftMargin+mlp.rightMargin; + } + firstInRow=false; + }else if(currentRowIndex + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/mastodon/src/main/res/drawable/bg_bottom_sheet.xml b/mastodon/src/main/res/drawable/bg_bottom_sheet.xml index bf1a899ef..92ee0279c 100644 --- a/mastodon/src/main/res/drawable/bg_bottom_sheet.xml +++ b/mastodon/src/main/res/drawable/bg_bottom_sheet.xml @@ -1,15 +1,9 @@ - - + + - - - - - - - + \ No newline at end of file diff --git a/mastodon/src/main/res/drawable/bg_button_m3_elevated.xml b/mastodon/src/main/res/drawable/bg_button_m3_elevated.xml new file mode 100644 index 000000000..9354428dc --- /dev/null +++ b/mastodon/src/main/res/drawable/bg_button_m3_elevated.xml @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/mastodon/src/main/res/drawable/bg_button_m3_filled_icon.xml b/mastodon/src/main/res/drawable/bg_button_m3_filled_icon.xml new file mode 100644 index 000000000..2c4ea8a12 --- /dev/null +++ b/mastodon/src/main/res/drawable/bg_button_m3_filled_icon.xml @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/mastodon/src/main/res/drawable/bg_button_m3_icon_label.xml b/mastodon/src/main/res/drawable/bg_button_m3_icon_label.xml new file mode 100644 index 000000000..73eec74d5 --- /dev/null +++ b/mastodon/src/main/res/drawable/bg_button_m3_icon_label.xml @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/mastodon/src/main/res/drawable/bg_button_m3_tonal_icon.xml b/mastodon/src/main/res/drawable/bg_button_m3_tonal_icon.xml new file mode 100644 index 000000000..81a9482a9 --- /dev/null +++ b/mastodon/src/main/res/drawable/bg_button_m3_tonal_icon.xml @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/mastodon/src/main/res/drawable/bg_handle_help.xml b/mastodon/src/main/res/drawable/bg_handle_help.xml new file mode 100644 index 000000000..ebfec2f2a --- /dev/null +++ b/mastodon/src/main/res/drawable/bg_handle_help.xml @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/mastodon/src/main/res/drawable/bg_m3_filled_text_field.xml b/mastodon/src/main/res/drawable/bg_m3_filled_text_field.xml new file mode 100644 index 000000000..a2159e9af --- /dev/null +++ b/mastodon/src/main/res/drawable/bg_m3_filled_text_field.xml @@ -0,0 +1,33 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/mastodon/src/main/res/drawable/bg_m3_filled_text_field_error.xml b/mastodon/src/main/res/drawable/bg_m3_filled_text_field_error.xml new file mode 100644 index 000000000..6c18cd7d2 --- /dev/null +++ b/mastodon/src/main/res/drawable/bg_m3_filled_text_field_error.xml @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/mastodon/src/main/res/drawable/bg_rect_ripple.xml b/mastodon/src/main/res/drawable/bg_rect_ripple.xml new file mode 100644 index 000000000..a6322aabf --- /dev/null +++ b/mastodon/src/main/res/drawable/bg_rect_ripple.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/mastodon/src/main/res/drawable/bg_spinner.xml b/mastodon/src/main/res/drawable/bg_spinner.xml new file mode 100644 index 000000000..0cfe96358 --- /dev/null +++ b/mastodon/src/main/res/drawable/bg_spinner.xml @@ -0,0 +1,8 @@ + + + + \ No newline at end of file diff --git a/mastodon/src/main/res/drawable/bg_user_info.xml b/mastodon/src/main/res/drawable/bg_user_info.xml new file mode 100644 index 000000000..c0fa41703 --- /dev/null +++ b/mastodon/src/main/res/drawable/bg_user_info.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/mastodon/src/main/res/drawable/divider_inset_16dp.xml b/mastodon/src/main/res/drawable/divider_inset_16dp.xml new file mode 100644 index 000000000..15394d995 --- /dev/null +++ b/mastodon/src/main/res/drawable/divider_inset_16dp.xml @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/mastodon/src/main/res/drawable/fg_link_card.xml b/mastodon/src/main/res/drawable/fg_link_card.xml new file mode 100644 index 000000000..5b4f100f8 --- /dev/null +++ b/mastodon/src/main/res/drawable/fg_link_card.xml @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/mastodon/src/main/res/drawable/fg_onboarding_ava.xml b/mastodon/src/main/res/drawable/fg_onboarding_ava.xml new file mode 100644 index 000000000..c0dbe1234 --- /dev/null +++ b/mastodon/src/main/res/drawable/fg_onboarding_ava.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/mastodon/src/main/res/drawable/fg_user_info_ava.xml b/mastodon/src/main/res/drawable/fg_user_info_ava.xml new file mode 100644 index 000000000..e36ace291 --- /dev/null +++ b/mastodon/src/main/res/drawable/fg_user_info_ava.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/mastodon/src/main/res/drawable/ic_arrow_drop_down_24px.xml b/mastodon/src/main/res/drawable/ic_arrow_drop_down_24px.xml new file mode 100644 index 000000000..56d1b3b04 --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_arrow_drop_down_24px.xml @@ -0,0 +1,9 @@ + + + diff --git a/mastodon/src/main/res/drawable/ic_arrow_right_24px.xml b/mastodon/src/main/res/drawable/ic_arrow_right_24px.xml new file mode 100644 index 000000000..55e7c6463 --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_arrow_right_24px.xml @@ -0,0 +1,10 @@ + + + diff --git a/mastodon/src/main/res/drawable/ic_arrow_upward_24px.xml b/mastodon/src/main/res/drawable/ic_arrow_upward_24px.xml new file mode 100644 index 000000000..15d81d9d6 --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_arrow_upward_24px.xml @@ -0,0 +1,9 @@ + + + diff --git a/mastodon/src/main/res/drawable/ic_badge_24px.xml b/mastodon/src/main/res/drawable/ic_badge_24px.xml new file mode 100644 index 000000000..489eff0b5 --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_badge_24px.xml @@ -0,0 +1,9 @@ + + + diff --git a/mastodon/src/main/res/drawable/ic_bookmark_fill1_24px.xml b/mastodon/src/main/res/drawable/ic_bookmark_fill1_24px.xml new file mode 100644 index 000000000..2b286bbc1 --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_bookmark_fill1_24px.xml @@ -0,0 +1,9 @@ + + + diff --git a/mastodon/src/main/res/drawable/ic_boost_24px.xml b/mastodon/src/main/res/drawable/ic_boost_24px.xml new file mode 100644 index 000000000..40373a17d --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_boost_24px.xml @@ -0,0 +1,9 @@ + + + diff --git a/mastodon/src/main/res/drawable/ic_boost_disabled_24px.xml b/mastodon/src/main/res/drawable/ic_boost_disabled_24px.xml new file mode 100644 index 000000000..859b454e8 --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_boost_disabled_24px.xml @@ -0,0 +1,15 @@ + + + + + diff --git a/mastodon/src/main/res/drawable/ic_boost_fill_alt_24px.xml b/mastodon/src/main/res/drawable/ic_boost_fill_alt_24px.xml new file mode 100644 index 000000000..259725654 --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_boost_fill_alt_24px.xml @@ -0,0 +1,9 @@ + + + diff --git a/mastodon/src/main/res/drawable/ic_boost_private.xml b/mastodon/src/main/res/drawable/ic_boost_private.xml new file mode 100644 index 000000000..6a2187dc9 --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_boost_private.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/mastodon/src/main/res/drawable/ic_campaign_24px.xml b/mastodon/src/main/res/drawable/ic_campaign_24px.xml new file mode 100644 index 000000000..0f34633b9 --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_campaign_24px.xml @@ -0,0 +1,9 @@ + + + diff --git a/mastodon/src/main/res/drawable/ic_category_academia.xml b/mastodon/src/main/res/drawable/ic_category_academia.xml deleted file mode 100644 index 4e0ac054a..000000000 --- a/mastodon/src/main/res/drawable/ic_category_academia.xml +++ /dev/null @@ -1,42 +0,0 @@ - - - - - - - - - - - - - - diff --git a/mastodon/src/main/res/drawable/ic_category_activism.xml b/mastodon/src/main/res/drawable/ic_category_activism.xml deleted file mode 100644 index 5001d06f8..000000000 --- a/mastodon/src/main/res/drawable/ic_category_activism.xml +++ /dev/null @@ -1,15 +0,0 @@ - - - - - diff --git a/mastodon/src/main/res/drawable/ic_category_all.xml b/mastodon/src/main/res/drawable/ic_category_all.xml deleted file mode 100644 index f94bf550c..000000000 --- a/mastodon/src/main/res/drawable/ic_category_all.xml +++ /dev/null @@ -1,18 +0,0 @@ - - - - - - diff --git a/mastodon/src/main/res/drawable/ic_category_art.xml b/mastodon/src/main/res/drawable/ic_category_art.xml deleted file mode 100644 index f0b8d0bc3..000000000 --- a/mastodon/src/main/res/drawable/ic_category_art.xml +++ /dev/null @@ -1,21 +0,0 @@ - - - - - - - diff --git a/mastodon/src/main/res/drawable/ic_category_food.xml b/mastodon/src/main/res/drawable/ic_category_food.xml deleted file mode 100644 index a514aeb3d..000000000 --- a/mastodon/src/main/res/drawable/ic_category_food.xml +++ /dev/null @@ -1,21 +0,0 @@ - - - - - - - diff --git a/mastodon/src/main/res/drawable/ic_category_furry.xml b/mastodon/src/main/res/drawable/ic_category_furry.xml deleted file mode 100644 index b97a508fc..000000000 --- a/mastodon/src/main/res/drawable/ic_category_furry.xml +++ /dev/null @@ -1,54 +0,0 @@ - - - - - - - - - - - - - - - - - - diff --git a/mastodon/src/main/res/drawable/ic_category_games.xml b/mastodon/src/main/res/drawable/ic_category_games.xml deleted file mode 100644 index e09596a00..000000000 --- a/mastodon/src/main/res/drawable/ic_category_games.xml +++ /dev/null @@ -1,27 +0,0 @@ - - - - - - - - - diff --git a/mastodon/src/main/res/drawable/ic_category_general.xml b/mastodon/src/main/res/drawable/ic_category_general.xml deleted file mode 100644 index d0a2de42c..000000000 --- a/mastodon/src/main/res/drawable/ic_category_general.xml +++ /dev/null @@ -1,15 +0,0 @@ - - - - - diff --git a/mastodon/src/main/res/drawable/ic_category_journalism.xml b/mastodon/src/main/res/drawable/ic_category_journalism.xml deleted file mode 100644 index 7decfc895..000000000 --- a/mastodon/src/main/res/drawable/ic_category_journalism.xml +++ /dev/null @@ -1,18 +0,0 @@ - - - - - - diff --git a/mastodon/src/main/res/drawable/ic_category_lgbt.xml b/mastodon/src/main/res/drawable/ic_category_lgbt.xml deleted file mode 100644 index 80b9af044..000000000 --- a/mastodon/src/main/res/drawable/ic_category_lgbt.xml +++ /dev/null @@ -1,24 +0,0 @@ - - - - - - - - diff --git a/mastodon/src/main/res/drawable/ic_category_music.xml b/mastodon/src/main/res/drawable/ic_category_music.xml deleted file mode 100644 index a2018b4e9..000000000 --- a/mastodon/src/main/res/drawable/ic_category_music.xml +++ /dev/null @@ -1,12 +0,0 @@ - - - - diff --git a/mastodon/src/main/res/drawable/ic_category_regional.xml b/mastodon/src/main/res/drawable/ic_category_regional.xml deleted file mode 100644 index bd128fba5..000000000 --- a/mastodon/src/main/res/drawable/ic_category_regional.xml +++ /dev/null @@ -1,15 +0,0 @@ - - - - - diff --git a/mastodon/src/main/res/drawable/ic_category_tech.xml b/mastodon/src/main/res/drawable/ic_category_tech.xml deleted file mode 100644 index e9e82f891..000000000 --- a/mastodon/src/main/res/drawable/ic_category_tech.xml +++ /dev/null @@ -1,12 +0,0 @@ - - - - diff --git a/mastodon/src/main/res/drawable/ic_category_unknown.xml b/mastodon/src/main/res/drawable/ic_category_unknown.xml deleted file mode 100644 index 8f76682f5..000000000 --- a/mastodon/src/main/res/drawable/ic_category_unknown.xml +++ /dev/null @@ -1,12 +0,0 @@ - - - - diff --git a/mastodon/src/main/res/drawable/ic_confirmation_number_24px.xml b/mastodon/src/main/res/drawable/ic_confirmation_number_24px.xml new file mode 100644 index 000000000..d5c4b03f3 --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_confirmation_number_24px.xml @@ -0,0 +1,9 @@ + + + diff --git a/mastodon/src/main/res/drawable/ic_feed_48px.xml b/mastodon/src/main/res/drawable/ic_feed_48px.xml new file mode 100644 index 000000000..72fc5dc66 --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_feed_48px.xml @@ -0,0 +1,9 @@ + + + diff --git a/mastodon/src/main/res/drawable/ic_fluent_arrow_up_16_filled.xml b/mastodon/src/main/res/drawable/ic_fluent_arrow_up_16_filled.xml deleted file mode 100644 index 39a248513..000000000 --- a/mastodon/src/main/res/drawable/ic_fluent_arrow_up_16_filled.xml +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/mastodon/src/main/res/drawable/ic_help_24px.xml b/mastodon/src/main/res/drawable/ic_help_24px.xml new file mode 100644 index 000000000..b19cb3450 --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_help_24px.xml @@ -0,0 +1,9 @@ + + + \ No newline at end of file diff --git a/mastodon/src/main/res/drawable/ic_info_fill1_24px.xml b/mastodon/src/main/res/drawable/ic_info_fill1_24px.xml new file mode 100644 index 000000000..077f39342 --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_info_fill1_24px.xml @@ -0,0 +1,9 @@ + + + diff --git a/mastodon/src/main/res/drawable/ic_list_alt_24px.xml b/mastodon/src/main/res/drawable/ic_list_alt_24px.xml new file mode 100644 index 000000000..5341250ce --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_list_alt_24px.xml @@ -0,0 +1,9 @@ + + + diff --git a/mastodon/src/main/res/drawable/ic_m3_cancel.xml b/mastodon/src/main/res/drawable/ic_m3_cancel.xml new file mode 100644 index 000000000..d11a326a2 --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_m3_cancel.xml @@ -0,0 +1,9 @@ + + + \ No newline at end of file diff --git a/mastodon/src/main/res/drawable/ic_more_vert_24px.xml b/mastodon/src/main/res/drawable/ic_more_vert_24px.xml new file mode 100644 index 000000000..bb501448f --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_more_vert_24px.xml @@ -0,0 +1,9 @@ + + + diff --git a/mastodon/src/main/res/drawable/ic_private_boost_24px.xml b/mastodon/src/main/res/drawable/ic_private_boost_24px.xml new file mode 100644 index 000000000..3c6670a9f --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_private_boost_24px.xml @@ -0,0 +1,15 @@ + + + + + diff --git a/mastodon/src/main/res/drawable/ic_private_boost_fill_alt_24px.xml b/mastodon/src/main/res/drawable/ic_private_boost_fill_alt_24px.xml new file mode 100644 index 000000000..6929d74aa --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_private_boost_fill_alt_24px.xml @@ -0,0 +1,15 @@ + + + + + diff --git a/mastodon/src/main/res/drawable/ic_switch_account_24px.xml b/mastodon/src/main/res/drawable/ic_switch_account_24px.xml new file mode 100644 index 000000000..f496736ba --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_switch_account_24px.xml @@ -0,0 +1,9 @@ + + + diff --git a/mastodon/src/main/res/drawable/ic_waving_hand_24px.xml b/mastodon/src/main/res/drawable/ic_waving_hand_24px.xml new file mode 100644 index 000000000..b43de6812 --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_waving_hand_24px.xml @@ -0,0 +1,9 @@ + + + diff --git a/mastodon/src/main/res/interpolator-v21/m3_sys_motion_easing_emphasized_accelerate.xml b/mastodon/src/main/res/interpolator-v21/m3_sys_motion_easing_emphasized_accelerate.xml new file mode 100644 index 000000000..2527b6607 --- /dev/null +++ b/mastodon/src/main/res/interpolator-v21/m3_sys_motion_easing_emphasized_accelerate.xml @@ -0,0 +1,22 @@ + + + + diff --git a/mastodon/src/main/res/interpolator-v21/m3_sys_motion_easing_standard_accelerate.xml b/mastodon/src/main/res/interpolator-v21/m3_sys_motion_easing_standard_accelerate.xml new file mode 100644 index 000000000..513147958 --- /dev/null +++ b/mastodon/src/main/res/interpolator-v21/m3_sys_motion_easing_standard_accelerate.xml @@ -0,0 +1,22 @@ + + + + diff --git a/mastodon/src/main/res/layout/alert_invite_link.xml b/mastodon/src/main/res/layout/alert_invite_link.xml new file mode 100644 index 000000000..a188985b6 --- /dev/null +++ b/mastodon/src/main/res/layout/alert_invite_link.xml @@ -0,0 +1,88 @@ + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/mastodon/src/main/res/layout/display_item_extended_footer.xml b/mastodon/src/main/res/layout/display_item_extended_footer.xml index 6ee095c9a..783ad2dd9 100644 --- a/mastodon/src/main/res/layout/display_item_extended_footer.xml +++ b/mastodon/src/main/res/layout/display_item_extended_footer.xml @@ -2,79 +2,20 @@ + android:paddingBottom="8dp" + android:divider="@drawable/divider_inset_16dp" + android:showDividers="middle"> - - - - -