diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index d3b8a94b0..5aafe8adb 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -8,25 +8,35 @@ assignees: '' --- **Describe the bug** + A clear and concise description of what the bug is. -**To Reproduce** +**To reproduce** + Steps to reproduce the behavior: 1. Go to '...' 2. Click on '....' 3. Scroll down to '....' 4. See error +**Does this happen in the official app?** + +Does this issue also occur with the respective upstream release? +(Please test using the respective `upstream-xxxxxx.apk` provided in [Releases](https://github.com/sk22/megalodon/releases) or at least using the current Mastodon version from the Play Store) + +> No / Yes + +> In case it does, please consider filing an [upstream bug report](https://github.com/mastodon/mastodon-android/issues) instead. +> If this bug is seriously impacting your usage or you think I might want to try to fix it for Megalodon, feel free to still create this issue! + **Screenshots and screen recordings** + If applicable, add screenshots (and screen recordings, if possible) to help explain your problem. **Version** + Megalodon version: [e.g. v1.1.4+fork.#] -**Additional context** -- Does this issue also occur with the respective upstream release? (Please test using the respective `upstream-xxxxxx.apk` provided in [Releases](https://github.com/sk22/megalodon/releases)) No / Yes (`mastodon#…`) - - > In this case, please consider filing an [upstream bug report](https://github.com/mastodon/mastodon-android/issues) instead. If this bug is seriously impacting your usage or you think I might want to try to fix it for Megalodon, feel free to still create this issue! - **Crash log** + If you know your way around Android development tools, please consider attaching a crash log, if possible. diff --git a/README.md b/README.md index 39fcd9f46..902a0bea6 100644 --- a/README.md +++ b/README.md @@ -152,6 +152,8 @@ There's also a handful of custom strings exclusive to this projects that would n * [Display server announcements](https://github.com/sk22/megalodon/commit/84179bc207d6b69cc2a770a3c28fa0a39b0b54e8) * [Create](https://github.com/sk22/megalodon/commit/294595513a45037359b31377aafc25ae5b58d8e7), [edit](https://github.com/sk22/megalodon/commit/d47797bf7ac8cff3f9ba1cfee219a1bb2af21da6) and [delete](https://github.com/sk22/megalodon/commit/54c29fd787fc2cd0dfd2787ad796b8190f795973) lists * [Soft-blocking (by blocking and immediately unblocking)](https://github.com/sk22/megalodon/commit/e75d350b7a2709259e9fc5138e0e1f361bdb0972) +* [Pinnable custom timelines](https://github.com/sk22/megalodon/pull/338/commits) +* Support for local-only posts ### Behavior @@ -175,6 +177,8 @@ There's also a handful of custom strings exclusive to this projects that would n * [Preserve whitespaces in HTML](https://github.com/sk22/megalodon/commit/7d876bddc7a07d98f0fecbf62b13bdb9fcce3412) * [Long-click to copy links](https://github.com/sk22/megalodon/commit/b32e32274923a94742a9926ef38785f746d41405) * Improved filtering using Mastodon 4.0 API: [#202](https://github.com/sk22/megalodon/pull/202), [#212](https://github.com/sk22/megalodon/pull/212), [#255](https://github.com/sk22/megalodon/pull/255) by [@thiagojedi](https://github.com/thiagojedi) +* [Support admin notifications](https://github.com/sk22/megalodon/commit/c12a6eaee6b609bc53eb0a45d9199f37d5241801) and [notifications for edited reblogged posts](https://github.com/sk22/megalodon/commit/900e8fb2e9353002c16d15e06b78d2731e121601) +* [Android file opener added back in addition to image picker](https://github.com/sk22/megalodon/commit/3a6ace53d5ab01e28077c9c930cb6ed487b78031) ### Visual @@ -190,6 +194,8 @@ There's also a handful of custom strings exclusive to this projects that would n * [Animations for interaction buttons](https://github.com/mastodon/mastodon-android/compare/master...sk22:megalodon:feature/animate-buttons) * [Dedicated icons for different notification types](https://github.com/sk22/megalodon/pull/178) by [@florian-obernberger](https://github.com/florian-obernberger) * Scale text according to system settings +* Header in timeline for followed hashtags +* [Indicator for missing alt texts](https://github.com/sk22/megalodon/commit/c0c276f03e793b78c478c17dfdef24a66ef7cedb) ## Building diff --git a/gradle.properties b/gradle.properties index 52f5917cb..3c6cdff76 100644 --- a/gradle.properties +++ b/gradle.properties @@ -16,4 +16,4 @@ org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 # https://developer.android.com/topic/libraries/support-library/androidx-rn android.useAndroidX=true # Automatically convert third-party libraries to use AndroidX -android.enableJetifier=true \ No newline at end of file +android.enableJetifier=false \ No newline at end of file diff --git a/mastodon/build.gradle b/mastodon/build.gradle index 8eec293d5..83130a3ae 100644 --- a/mastodon/build.gradle +++ b/mastodon/build.gradle @@ -9,8 +9,8 @@ android { applicationId "org.joinmastodon.android.sk" minSdk 23 targetSdk 33 - versionCode 67 - versionName "1.1.5+fork.67" + versionCode 75 + versionName "1.1.5+fork.75" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" resConfigs "ar-rSA", "be-rBY", "bn-rBD", "bs-rBA", "ca-rES", "cs-rCZ", "de-rDE", "el-rGR", "es-rES", "eu-rES", "fi-rFI", "fil-rPH", "fr-rFR", "ga-rIE", "gd-rGB", "gl-rES", "hi-rIN", "hr-rHR", "hu-rHU", "hy-rAM", "in-rID", "is-rIS", "it-rIT", "iw-rIL", "ja-rJP", "kab", "ko-rKR", "nl-rNL", "oc-rFR", "pl-rPL", "pt-rBR", "pt-rPT", "ro-rRO", "ru-rRU", "si-rLK", "sl-rSI", "sv-rSE", "th-rTH", "tr-rTR", "uk-rUA", "vi-rVN", "zh-rCN", "zh-rTW" } @@ -70,6 +70,7 @@ dependencies { implementation 'com.squareup:otto:1.3.8' implementation 'de.psdev:async-otto:1.0.3' implementation 'org.parceler:parceler-api:1.1.12' + implementation 'com.github.bottom-software-foundation:bottom-java:2.1.0' annotationProcessor 'org.parceler:parceler:1.1.12' coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:1.1.5' diff --git a/mastodon/src/github/java/org/joinmastodon/android/updater/GithubSelfUpdaterImpl.java b/mastodon/src/github/java/org/joinmastodon/android/updater/GithubSelfUpdaterImpl.java index 4adaa4c59..a186b952c 100644 --- a/mastodon/src/github/java/org/joinmastodon/android/updater/GithubSelfUpdaterImpl.java +++ b/mastodon/src/github/java/org/joinmastodon/android/updater/GithubSelfUpdaterImpl.java @@ -14,12 +14,14 @@ import android.os.Build; import android.util.Log; import android.widget.Toast; +import com.google.gson.JsonArray; import com.google.gson.JsonElement; import com.google.gson.JsonObject; import com.google.gson.JsonParser; import org.joinmastodon.android.BuildConfig; import org.joinmastodon.android.E; +import org.joinmastodon.android.GlobalUserPreferences; import org.joinmastodon.android.MastodonApp; import org.joinmastodon.android.R; import org.joinmastodon.android.api.MastodonAPIController; @@ -113,64 +115,70 @@ public class GithubSelfUpdaterImpl extends GithubSelfUpdater{ private void actuallyCheckForUpdates(){ Request req=new Request.Builder() - .url("https://api.github.com/repos/sk22/megalodon/releases/latest") + .url("https://api.github.com/repos/sk22/megalodon/releases") .build(); Call call=MastodonAPIController.getHttpClient().newCall(req); try(Response resp=call.execute()){ - JsonObject obj=JsonParser.parseReader(resp.body().charStream()).getAsJsonObject(); - String tag=obj.get("tag_name").getAsString(); - String changelog=obj.get("body").getAsString(); - Pattern pattern=Pattern.compile("v?(\\d+)\\.(\\d+)\\.(\\d+)\\+fork\\.(\\d+)"); - Matcher matcher=pattern.matcher(tag); - if(!matcher.find()){ - Log.w(TAG, "actuallyCheckForUpdates: release tag has wrong format: "+tag); - return; - } - int newMajor=Integer.parseInt(matcher.group(1)), - newMinor=Integer.parseInt(matcher.group(2)), - newRevision=Integer.parseInt(matcher.group(3)), - newForkNumber=Integer.parseInt(matcher.group(4)); - matcher=pattern.matcher(BuildConfig.VERSION_NAME); - String[] currentParts=BuildConfig.VERSION_NAME.split("[.+]"); - if(!matcher.find()){ - Log.w(TAG, "actuallyCheckForUpdates: current version has wrong format: "+BuildConfig.VERSION_NAME); - return; - } - int curMajor=Integer.parseInt(matcher.group(1)), - curMinor=Integer.parseInt(matcher.group(2)), - curRevision=Integer.parseInt(matcher.group(3)), - curForkNumber=Integer.parseInt(matcher.group(4)); - long newVersion=((long)newMajor << 32) | ((long)newMinor << 16) | newRevision; - long curVersion=((long)curMajor << 32) | ((long)curMinor << 16) | curRevision; - if(newVersion>curVersion || newForkNumber>curForkNumber){ - String version=newMajor+"."+newMinor+"."+newRevision+"+fork."+newForkNumber; - Log.d(TAG, "actuallyCheckForUpdates: new version: "+version); - for(JsonElement el:obj.getAsJsonArray("assets")){ - JsonObject asset=el.getAsJsonObject(); - if("megalodon.apk".equals(asset.get("name").getAsString()) && "application/vnd.android.package-archive".equals(asset.get("content_type").getAsString()) && "uploaded".equals(asset.get("state").getAsString())){ - long size=asset.get("size").getAsLong(); - String url=asset.get("browser_download_url").getAsString(); + JsonArray arr=JsonParser.parseReader(resp.body().charStream()).getAsJsonArray(); + for (JsonElement jsonElement : arr) { + JsonObject obj = jsonElement.getAsJsonObject(); + if (obj.get("prerelease").getAsBoolean() && !GlobalUserPreferences.enablePreReleases) continue; - UpdateInfo info=new UpdateInfo(); - info.size=size; - info.version=version; - info.changelog=changelog; - this.info=info; + String tag=obj.get("tag_name").getAsString(); + String changelog=obj.get("body").getAsString(); + Pattern pattern=Pattern.compile("v?(\\d+)\\.(\\d+)\\.(\\d+)\\+fork\\.(\\d+)"); + Matcher matcher=pattern.matcher(tag); + if(!matcher.find()){ + Log.w(TAG, "actuallyCheckForUpdates: release tag has wrong format: "+tag); + return; + } + int newMajor=Integer.parseInt(matcher.group(1)), + newMinor=Integer.parseInt(matcher.group(2)), + newRevision=Integer.parseInt(matcher.group(3)), + newForkNumber=Integer.parseInt(matcher.group(4)); + matcher=pattern.matcher(BuildConfig.VERSION_NAME); + String[] currentParts=BuildConfig.VERSION_NAME.split("[.+]"); + if(!matcher.find()){ + Log.w(TAG, "actuallyCheckForUpdates: current version has wrong format: "+BuildConfig.VERSION_NAME); + return; + } + int curMajor=Integer.parseInt(matcher.group(1)), + curMinor=Integer.parseInt(matcher.group(2)), + curRevision=Integer.parseInt(matcher.group(3)), + curForkNumber=Integer.parseInt(matcher.group(4)); + long newVersion=((long)newMajor << 32) | ((long)newMinor << 16) | newRevision; + long curVersion=((long)curMajor << 32) | ((long)curMinor << 16) | curRevision; + if(newVersion>curVersion || newForkNumber>curForkNumber){ + String version=newMajor+"."+newMinor+"."+newRevision+"+fork."+newForkNumber; + Log.d(TAG, "actuallyCheckForUpdates: new version: "+version); + for(JsonElement el:obj.getAsJsonArray("assets")){ + JsonObject asset=el.getAsJsonObject(); + if("megalodon.apk".equals(asset.get("name").getAsString()) && "application/vnd.android.package-archive".equals(asset.get("content_type").getAsString()) && "uploaded".equals(asset.get("state").getAsString())){ + long size=asset.get("size").getAsLong(); + String url=asset.get("browser_download_url").getAsString(); - getPrefs().edit() - .putLong("apkSize", size) - .putString("version", version) - .putString("apkURL", url) - .putString("changelog", changelog) - .putInt("checkedByBuild", BuildConfig.VERSION_CODE) - .remove("downloadID") - .apply(); + UpdateInfo info=new UpdateInfo(); + info.size=size; + info.version=version; + info.changelog=changelog; + this.info=info; - break; + getPrefs().edit() + .putLong("apkSize", size) + .putString("version", version) + .putString("apkURL", url) + .putString("changelog", changelog) + .putInt("checkedByBuild", BuildConfig.VERSION_CODE) + .remove("downloadID") + .apply(); + + break; + } } } + getPrefs().edit().putLong("lastCheck", System.currentTimeMillis()).apply(); + break; } - getPrefs().edit().putLong("lastCheck", System.currentTimeMillis()).apply(); }catch(Exception x){ Log.w(TAG, "actuallyCheckForUpdates", x); }finally{ diff --git a/mastodon/src/main/AndroidManifest.xml b/mastodon/src/main/AndroidManifest.xml index 059630c25..7d9c6f2e9 100644 --- a/mastodon/src/main/AndroidManifest.xml +++ b/mastodon/src/main/AndroidManifest.xml @@ -12,6 +12,13 @@ + + + + + + + >>() {}.getType(); + private final static Type pinnedTimelinesType = new TypeToken>>() {}.getType(); public static Map> recentLanguages; + public static Map> pinnedTimelines; + public static Set accountsWithLocalOnlySupport; + public static Set accountsInGlitchMode; private static SharedPreferences getPrefs(){ return MastodonApp.context.getSharedPreferences("global", Context.MODE_PRIVATE); } private static T fromJson(String json, Type type, T orElse) { + if (json == null) return orElse; try { return gson.fromJson(json, type); } catch (JsonSyntaxException ignored) { return orElse; } } @@ -55,7 +72,7 @@ public class GlobalUserPreferences{ showReplies=prefs.getBoolean("showReplies", true); showBoosts=prefs.getBoolean("showBoosts", true); loadNewPosts=prefs.getBoolean("loadNewPosts", true); - showFederatedTimeline=prefs.getBoolean("showFederatedTimeline", !BuildConfig.BUILD_TYPE.equals("playRelease")); + showNewPostsButton=prefs.getBoolean("showNewPostsButton", true); showInteractionCounts=prefs.getBoolean("showInteractionCounts", false); alwaysExpandContentWarnings=prefs.getBoolean("alwaysExpandContentWarnings", false); disableMarquee=prefs.getBoolean("disableMarquee", false); @@ -66,9 +83,20 @@ public class GlobalUserPreferences{ uniformNotificationIcon=prefs.getBoolean("uniformNotificationIcon", false); reduceMotion=prefs.getBoolean("reduceMotion", false); keepOnlyLatestNotification=prefs.getBoolean("keepOnlyLatestNotification", false); + disableAltTextReminder=prefs.getBoolean("disableAltTextReminder", false); + showAltIndicator=prefs.getBoolean("showAltIndicator", true); + showNoAltIndicator=prefs.getBoolean("showNoAltIndicator", true); + enablePreReleases=prefs.getBoolean("enablePreReleases", false); + prefixRepliesWithRe=prefs.getBoolean("prefixRepliesWithRe", false); + bottomEncoding=prefs.getBoolean("bottomEncoding", false); + collapseLongPosts=prefs.getBoolean("collapseLongPosts", true); + spectatorMode=prefs.getBoolean("spectatorMode", false); publishButtonText=prefs.getString("publishButtonText", ""); theme=ThemePreference.values()[prefs.getInt("theme", 0)]; - recentLanguages=fromJson(prefs.getString("recentLanguages", "{}"), recentLanguagesType, new HashMap<>()); + recentLanguages=fromJson(prefs.getString("recentLanguages", null), recentLanguagesType, new HashMap<>()); + pinnedTimelines=fromJson(prefs.getString("pinnedTimelines", null), pinnedTimelinesType, new HashMap<>()); + accountsWithLocalOnlySupport=prefs.getStringSet("accountsWithLocalOnlySupport", new HashSet<>()); + accountsInGlitchMode=prefs.getStringSet("accountsInGlitchMode", new HashSet<>()); try { color=ColorPreference.valueOf(prefs.getString("color", ColorPreference.PINK.name())); @@ -85,7 +113,7 @@ public class GlobalUserPreferences{ .putBoolean("showReplies", showReplies) .putBoolean("showBoosts", showBoosts) .putBoolean("loadNewPosts", loadNewPosts) - .putBoolean("showFederatedTimeline", showFederatedTimeline) + .putBoolean("showNewPostsButton", showNewPostsButton) .putBoolean("trueBlackTheme", trueBlackTheme) .putBoolean("showInteractionCounts", showInteractionCounts) .putBoolean("alwaysExpandContentWarnings", alwaysExpandContentWarnings) @@ -96,10 +124,21 @@ public class GlobalUserPreferences{ .putBoolean("uniformNotificationIcon", uniformNotificationIcon) .putBoolean("reduceMotion", reduceMotion) .putBoolean("keepOnlyLatestNotification", keepOnlyLatestNotification) + .putBoolean("disableAltTextReminder", disableAltTextReminder) + .putBoolean("showAltIndicator", showAltIndicator) + .putBoolean("showNoAltIndicator", showNoAltIndicator) + .putBoolean("enablePreReleases", enablePreReleases) + .putBoolean("prefixRepliesWithRe", prefixRepliesWithRe) + .putBoolean("collapseLongPosts", collapseLongPosts) + .putBoolean("spectatorMode", spectatorMode) .putString("publishButtonText", publishButtonText) + .putBoolean("bottomEncoding", bottomEncoding) .putInt("theme", theme.ordinal()) .putString("color", color.name()) .putString("recentLanguages", gson.toJson(recentLanguages)) + .putString("pinnedTimelines", gson.toJson(pinnedTimelines)) + .putStringSet("accountsWithLocalOnlySupport", accountsWithLocalOnlySupport) + .putStringSet("accountsInGlitchMode", accountsInGlitchMode) .apply(); } diff --git a/mastodon/src/main/java/org/joinmastodon/android/MainActivity.java b/mastodon/src/main/java/org/joinmastodon/android/MainActivity.java index eab161889..ddfb01371 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/MainActivity.java +++ b/mastodon/src/main/java/org/joinmastodon/android/MainActivity.java @@ -39,12 +39,13 @@ public class MainActivity extends FragmentStackActivity{ AccountSession session; Bundle args=new Bundle(); Intent intent=getIntent(); - if(intent.getBooleanExtra("fromNotification", false)){ + boolean fromNotification = intent.getBooleanExtra("fromNotification", false); + boolean hasNotification = intent.hasExtra("notification"); + if(fromNotification){ String accountID=intent.getStringExtra("accountID"); try{ session=AccountSessionManager.getInstance().getAccount(accountID); - if(!intent.hasExtra("notification")) - args.putString("tab", "notifications"); + if(!hasNotification) args.putString("tab", "notifications"); }catch(IllegalStateException x){ session=AccountSessionManager.getInstance().getLastActiveAccount(); } @@ -54,13 +55,13 @@ public class MainActivity extends FragmentStackActivity{ args.putString("account", session.getID()); Fragment fragment=session.activated ? new HomeFragment() : new AccountActivationFragment(); fragment.setArguments(args); - showFragmentClearingBackStack(fragment); - if(intent.getBooleanExtra("fromNotification", false) && intent.hasExtra("notification")){ + if(fromNotification && hasNotification){ Notification notification=Parcels.unwrap(intent.getParcelableExtra("notification")); showFragmentForNotification(notification, session.getID()); - }else if(intent.getBooleanExtra("compose", false)){ + } else if (intent.getBooleanExtra("compose", false)){ showCompose(); - }else{ + } else { + showFragmentClearingBackStack(fragment); maybeRequestNotificationsPermission(); } } @@ -139,4 +140,31 @@ public class MainActivity extends FragmentStackActivity{ requestPermissions(new String[]{Manifest.permission.POST_NOTIFICATIONS}, 100); } } + + /** + * when opening app through a notification: if (thread) fragment "can go back", clear back stack + * and show home fragment. upstream's implementation doesn't require this as it opens home first + * and then immediately switches to the notification's ThreadFragment. this causes a black + * screen in megalodon, for some reason, so i'm working around this that way. + */ + @Override + public void onBackPressed() { + Fragment currentFragment = getFragmentManager().findFragmentById( + (fragmentContainers.get(fragmentContainers.size() - 1)).getId() + ); + Bundle currentArgs = currentFragment.getArguments(); + if (this.fragmentContainers.size() == 1 + && currentArgs != null + && currentArgs.getBoolean("_can_go_back", false) + && currentArgs.containsKey("account")) { + Bundle args = new Bundle(); + args.putString("account", currentArgs.getString("account")); + args.putString("tab", "notifications"); + Fragment fragment=new HomeFragment(); + fragment.setArguments(args); + showFragmentClearingBackStack(fragment); + } else { + super.onBackPressed(); + } + } } diff --git a/mastodon/src/main/java/org/joinmastodon/android/PushNotificationReceiver.java b/mastodon/src/main/java/org/joinmastodon/android/PushNotificationReceiver.java index a89bcd996..5023e1996 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/PushNotificationReceiver.java +++ b/mastodon/src/main/java/org/joinmastodon/android/PushNotificationReceiver.java @@ -144,12 +144,18 @@ public class PushNotificationReceiver extends BroadcastReceiver{ .setAutoCancel(true) .setColor(context.getColor(R.color.primary_700)); - if (!GlobalUserPreferences.uniformNotificationIcon) switch (pn.notificationType) { - case FAVORITE -> builder.setSmallIcon(R.drawable.ic_fluent_star_24_filled); - case REBLOG -> builder.setSmallIcon(R.drawable.ic_fluent_arrow_repeat_all_24_filled); - case FOLLOW -> builder.setSmallIcon(R.drawable.ic_fluent_person_add_24_filled); - case MENTION -> builder.setSmallIcon(R.drawable.ic_fluent_mention_24_filled); - case POLL -> builder.setSmallIcon(R.drawable.ic_fluent_poll_24_filled); + if (!GlobalUserPreferences.uniformNotificationIcon) { + builder.setSmallIcon(switch (pn.notificationType) { + case FAVORITE -> R.drawable.ic_fluent_star_24_filled; + case REBLOG -> R.drawable.ic_fluent_arrow_repeat_all_24_filled; + case FOLLOW -> R.drawable.ic_fluent_person_add_24_filled; + case MENTION -> R.drawable.ic_fluent_mention_24_filled; + case POLL -> R.drawable.ic_fluent_poll_24_filled; + case STATUS -> R.drawable.ic_fluent_chat_24_filled; + case UPDATE -> R.drawable.ic_fluent_history_24_filled; + case REPORT -> R.drawable.ic_fluent_warning_24_filled; + case SIGN_UP -> R.drawable.ic_fluent_person_available_24_filled; + }); } if(avatar!=null){ diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/requests/accounts/SetAccountFollowed.java b/mastodon/src/main/java/org/joinmastodon/android/api/requests/accounts/SetAccountFollowed.java index 1799120a9..4d7ca8571 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/api/requests/accounts/SetAccountFollowed.java +++ b/mastodon/src/main/java/org/joinmastodon/android/api/requests/accounts/SetAccountFollowed.java @@ -4,6 +4,10 @@ import org.joinmastodon.android.api.MastodonAPIRequest; import org.joinmastodon.android.model.Relationship; public class SetAccountFollowed extends MastodonAPIRequest{ + public SetAccountFollowed(String id, boolean followed, boolean showReblogs){ + this(id, followed, showReblogs, false); + } + public SetAccountFollowed(String id, boolean followed, boolean showReblogs, boolean notify){ super(HttpMethod.POST, "/accounts/"+id+"/"+(followed ? "follow" : "unfollow"), Relationship.class); if(followed) 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 new file mode 100644 index 000000000..19bda79ca --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/api/requests/lists/GetList.java @@ -0,0 +1,10 @@ +package org.joinmastodon.android.api.requests.lists; + +import org.joinmastodon.android.api.MastodonAPIRequest; +import org.joinmastodon.android.model.ListTimeline; + +public class GetList extends MastodonAPIRequest { + public GetList(String id) { + super(HttpMethod.GET, "/lists/" + id, ListTimeline.class); + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/requests/notifications/RegisterForPushNotifications.java b/mastodon/src/main/java/org/joinmastodon/android/api/requests/notifications/RegisterForPushNotifications.java index 5b096f2ba..b1ef8ace9 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/api/requests/notifications/RegisterForPushNotifications.java +++ b/mastodon/src/main/java/org/joinmastodon/android/api/requests/notifications/RegisterForPushNotifications.java @@ -9,7 +9,7 @@ public class RegisterForPushNotifications extends MastodonAPIRequest{ + private final PushSubscription.Policy policy; + public UpdatePushSettings(PushSubscription.Alerts alerts, PushSubscription.Policy policy){ super(HttpMethod.PUT, "/push/subscription", PushSubscription.class); setRequestBody(new Request(alerts, policy)); + this.policy=policy; + } + + @Override + public void validateAndPostprocessResponse(PushSubscription respObj, Response httpResponse) throws IOException{ + super.validateAndPostprocessResponse(respObj, httpResponse); + respObj.policy=policy; } private static class Request{ public Data data=new Data(); + public PushSubscription.Policy policy; public Request(PushSubscription.Alerts alerts, PushSubscription.Policy policy){ this.data.alerts=alerts; - this.data.policy=policy; + this.policy=policy; } private static class Data{ public PushSubscription.Alerts alerts; - public PushSubscription.Policy policy; } } } diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/requests/statuses/CreateStatus.java b/mastodon/src/main/java/org/joinmastodon/android/api/requests/statuses/CreateStatus.java index 6a9c0cea1..bd6b307b1 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/api/requests/statuses/CreateStatus.java +++ b/mastodon/src/main/java/org/joinmastodon/android/api/requests/statuses/CreateStatus.java @@ -39,6 +39,7 @@ public class CreateStatus extends MastodonAPIRequest{ public Poll poll; public String inReplyToId; public boolean sensitive; + public boolean localOnly; public String spoilerText; public StatusPrivacy visibility; public Instant scheduledAt; diff --git a/mastodon/src/main/java/org/joinmastodon/android/events/HashtagUpdatedEvent.java b/mastodon/src/main/java/org/joinmastodon/android/events/HashtagUpdatedEvent.java new file mode 100644 index 000000000..679c65793 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/events/HashtagUpdatedEvent.java @@ -0,0 +1,11 @@ +package org.joinmastodon.android.events; + +public class HashtagUpdatedEvent { + public final String name; + public final boolean following; + + public HashtagUpdatedEvent(String name, boolean following) { + this.name = name; + this.following = following; + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/events/ListDeletedEvent.java b/mastodon/src/main/java/org/joinmastodon/android/events/ListDeletedEvent.java new file mode 100644 index 000000000..9824bb233 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/events/ListDeletedEvent.java @@ -0,0 +1,9 @@ +package org.joinmastodon.android.events; + +public class ListDeletedEvent { + public final String id; + + public ListDeletedEvent(String id) { + this.id = id; + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/events/ListUpdatedCreatedEvent.java b/mastodon/src/main/java/org/joinmastodon/android/events/ListUpdatedCreatedEvent.java new file mode 100644 index 000000000..89efffda2 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/events/ListUpdatedCreatedEvent.java @@ -0,0 +1,15 @@ +package org.joinmastodon.android.events; + +import org.joinmastodon.android.model.ListTimeline; + +public class ListUpdatedCreatedEvent { + public final String id; + public final String title; + public final ListTimeline.RepliesPolicy repliesPolicy; + + public ListUpdatedCreatedEvent(String id, String title, ListTimeline.RepliesPolicy repliesPolicy) { + this.id = id; + this.title = title; + this.repliesPolicy = repliesPolicy; + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/AccountTimelineFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/AccountTimelineFragment.java index 786bf6ca4..2c3050ee5 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/AccountTimelineFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/AccountTimelineFragment.java @@ -15,12 +15,15 @@ import org.joinmastodon.android.events.RemoveAccountPostsEvent; import org.joinmastodon.android.events.StatusCreatedEvent; import org.joinmastodon.android.events.StatusUnpinnedEvent; import org.joinmastodon.android.model.Account; +import org.joinmastodon.android.model.Filter; import org.joinmastodon.android.model.Status; import org.joinmastodon.android.ui.displayitems.HeaderStatusDisplayItem; +import org.joinmastodon.android.utils.StatusFilterPredicate; import org.parceler.Parcels; import java.util.Collections; import java.util.List; +import java.util.stream.Collectors; import me.grishka.appkit.api.SimpleCallback; @@ -60,8 +63,8 @@ public class AccountTimelineFragment extends StatusListFragment{ .setCallback(new SimpleCallback<>(this){ @Override public void onSuccess(List result){ - if(getActivity()==null) - return; + if(getActivity()==null) return; + result=result.stream().filter(new StatusFilterPredicate(accountID, Filter.FilterContext.ACCOUNT)).collect(Collectors.toList()); onDataLoaded(result, !result.isEmpty()); } }) diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/AnnouncementsFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/AnnouncementsFragment.java index 74ea115e8..a87b8c465 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/AnnouncementsFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/AnnouncementsFragment.java @@ -66,7 +66,7 @@ public class AnnouncementsFragment extends BaseStatusListFragment instanceUser.avatar = instanceUser.avatarStatic = instance.thumbnail; instanceUser.emojis = List.of(); Status fakeStatus = a.toStatus(); - TextStatusDisplayItem textItem = new TextStatusDisplayItem(a.id, HtmlParser.parse(a.content, a.emojis, a.mentions, a.tags, accountID), this, fakeStatus); + TextStatusDisplayItem textItem = new TextStatusDisplayItem(a.id, HtmlParser.parse(a.content, a.emojis, a.mentions, a.tags, accountID), this, fakeStatus, true); textItem.textSelectable = true; return List.of( HeaderStatusDisplayItem.fromAnnouncement(a, fakeStatus, instanceUser, this, accountID, this::onMarkAsRead), @@ -77,12 +77,7 @@ public class AnnouncementsFragment extends BaseStatusListFragment public void onMarkAsRead(String id) { if (unreadIDs == null) return; unreadIDs.remove(id); - if (unreadIDs.size() == 0) setResult(true, null); - } - - @Override - public void onDestroy() { - super.onDestroy(); + if (unreadIDs.isEmpty()) setResult(true, null); } @Override @@ -97,11 +92,13 @@ public class AnnouncementsFragment extends BaseStatusListFragment .setCallback(new SimpleCallback<>(this){ @Override public void onSuccess(List result){ + if (getActivity() == null) return; List unread = result.stream().filter(a -> !a.read).collect(toList()); List read = result.stream().filter(a -> a.read).collect(toList()); onDataLoaded(unread, true); onDataLoaded(read, false); - unreadIDs = unread.stream().map(a -> a.id).collect(toList()); + if (unread.isEmpty()) setResult(true, null); + else unreadIDs = unread.stream().map(a -> a.id).collect(toList()); } }) .exec(accountID); 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 e60b163f3..1b879b2a5 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/BaseStatusListFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/BaseStatusListFragment.java @@ -19,6 +19,7 @@ import android.view.ViewGroup; import android.view.WindowInsets; import android.view.animation.TranslateAnimation; import android.widget.ImageButton; +import android.widget.ImageButton; import android.widget.Toolbar; import org.joinmastodon.android.E; @@ -43,6 +44,7 @@ import org.joinmastodon.android.ui.displayitems.PollFooterStatusDisplayItem; import org.joinmastodon.android.ui.displayitems.PollOptionStatusDisplayItem; import org.joinmastodon.android.ui.displayitems.StatusDisplayItem; import org.joinmastodon.android.ui.displayitems.TextStatusDisplayItem; +import org.joinmastodon.android.ui.displayitems.WarningFilteredStatusDisplayItem; import org.joinmastodon.android.ui.photoviewer.PhotoViewer; import org.joinmastodon.android.ui.photoviewer.PhotoViewerHost; import org.joinmastodon.android.ui.utils.UiUtils; @@ -59,6 +61,8 @@ import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.recyclerview.widget.GridLayoutManager; 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.fragments.BaseRecyclerFragment; @@ -79,9 +83,15 @@ public abstract class BaseStatusListFragment exten protected HashMap knownAccounts=new HashMap<>(); protected HashMap relationships=new HashMap<>(); protected Rect tmpRect=new Rect(); + protected ImageButton fab; public BaseStatusListFragment(){ super(20); + if (withComposeButton()) setListLayoutId(R.layout.recycler_fragment_with_fab); + } + + protected boolean withComposeButton() { + return false; } @Override @@ -95,6 +105,8 @@ public abstract class BaseStatusListFragment exten setRetainInstance(true); } + + @Override protected RecyclerView.Adapter getAdapter(){ return adapter=new DisplayItemsAdapter(); @@ -349,6 +361,13 @@ public abstract class BaseStatusListFragment exten list.setItemAnimator(new BetterItemAnimator()); ((UsableRecyclerView) list).setIncludeMarginsInItemHitbox(true); updateToolbar(); + + if (withComposeButton()) { + fab = view.findViewById(R.id.fab); + fab.setVisibility(View.VISIBLE); + fab.setOnClickListener(this::onFabClick); + fab.setOnLongClickListener(this::onFabLongClick); + } } @Override @@ -514,7 +533,7 @@ public abstract class BaseStatusListFragment exten Status status=holder.getItem().status; status.spoilerRevealed=!status.spoilerRevealed; if(!TextUtils.isEmpty(status.spoilerText)){ - TextStatusDisplayItem.Holder text=findHolderOfType(holder.getItemID(), TextStatusDisplayItem.Holder.class); + TextStatusDisplayItem.Holder text = findHolderOfType(holder.getItemID(), TextStatusDisplayItem.Holder.class); if(text!=null){ adapter.notifyItemChanged(text.getAbsoluteAdapterPosition()); } @@ -523,6 +542,23 @@ public abstract class BaseStatusListFragment exten updateImagesSpoilerState(status, holder.getItemID()); } + public void onEnableExpandable(TextStatusDisplayItem.Holder holder, boolean expandable) { + if (holder.getItem().status.textExpandable != expandable && list != null) { + holder.getItem().status.textExpandable = expandable; + HeaderStatusDisplayItem.Holder header = findHolderOfType(holder.getItemID(), HeaderStatusDisplayItem.Holder.class); + if (header != null) header.rebind(); + holder.rebind(); + } + } + + public void onToggleExpanded(Status status, String itemID) { + status.textExpanded = !status.textExpanded; + TextStatusDisplayItem.Holder text=findHolderOfType(itemID, TextStatusDisplayItem.Holder.class); + HeaderStatusDisplayItem.Holder header=findHolderOfType(itemID, HeaderStatusDisplayItem.Holder.class); + if (text != null) text.rebind(); + if (header != null) header.rebind(); + } + protected void updateImagesSpoilerState(Status status, String itemID){ ArrayList updatedPositions=new ArrayList<>(); for(ImageStatusDisplayItem.Holder photo:(List)findAllHoldersOfType(itemID, ImageStatusDisplayItem.Holder.class)){ @@ -540,6 +576,15 @@ public abstract class BaseStatusListFragment exten public void onGapClick(GapStatusDisplayItem.Holder item){} + public void onWarningClick(WarningFilteredStatusDisplayItem.Holder warning){ + int startPos = warning.getAbsoluteAdapterPosition(); + displayItems.remove(startPos); + displayItems.addAll(startPos, warning.filteredItems); + adapter.notifyItemRangeInserted(startPos, warning.filteredItems.size() - 1); + if (startPos == 0) scrollToTop(); + warning.getItem().status.filterRevealed = true; + } + public String getAccountID(){ return accountID; } @@ -655,6 +700,16 @@ public abstract class BaseStatusListFragment exten currentPhotoViewer.onPause(); } + protected void onFabClick(View v){ + Bundle args=new Bundle(); + args.putString("account", accountID); + Nav.go(getActivity(), ComposeFragment.class, args); + } + + protected boolean onFabLongClick(View v) { + return UiUtils.pickAccountForCompose(getActivity(), accountID); + } + protected class DisplayItemsAdapter extends UsableRecyclerView.Adapter> implements ImageLoaderRecyclerAdapter{ public DisplayItemsAdapter(){ diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/BookmarkedStatusListFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/BookmarkedStatusListFragment.java index 501ff1275..6fd12d95d 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/BookmarkedStatusListFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/BookmarkedStatusListFragment.java @@ -25,6 +25,7 @@ public class BookmarkedStatusListFragment extends StatusListFragment{ .setCallback(new SimpleCallback<>(this){ @Override public void onSuccess(HeaderPaginationList result){ + if (getActivity() == null) return; if(result.nextPageUri!=null) nextMaxID=result.nextPageUri.getQueryParameter("max_id"); else 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 0a85b56ce..928f31c2c 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/ComposeFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/ComposeFragment.java @@ -3,9 +3,9 @@ package org.joinmastodon.android.fragments; import static org.joinmastodon.android.GlobalUserPreferences.recentLanguages; import static org.joinmastodon.android.api.requests.statuses.CreateStatus.DRAFTS_AFTER_INSTANT; import static org.joinmastodon.android.api.requests.statuses.CreateStatus.getDraftInstant; +import static org.joinmastodon.android.ui.utils.UiUtils.isPhotoPickerAvailable; import static org.joinmastodon.android.utils.MastodonLanguage.allLanguages; import static org.joinmastodon.android.utils.MastodonLanguage.defaultRecentLanguages; -import static android.os.ext.SdkExtensions.getExtensionVersion; import android.animation.ObjectAnimator; import android.annotation.SuppressLint; @@ -42,6 +42,7 @@ import android.text.TextWatcher; import android.text.format.DateFormat; import android.util.Log; import android.view.Gravity; +import android.view.HapticFeedbackConstants; import android.view.LayoutInflater; import android.view.Menu; import android.view.MenuInflater; @@ -66,6 +67,7 @@ import android.widget.ScrollView; import android.widget.TextView; import android.widget.Toast; +import com.github.bottomSoftwareFoundation.bottom.Bottom; import com.twitter.twittertext.TwitterTextEmojiRegex; import org.joinmastodon.android.E; @@ -115,6 +117,7 @@ import org.joinmastodon.android.ui.views.LinkedTextView; import org.joinmastodon.android.ui.views.ReorderableLinearLayout; import org.joinmastodon.android.ui.views.SizeListenerLinearLayout; import org.joinmastodon.android.utils.MastodonLanguage; +import org.joinmastodon.android.utils.StatusTextEncoder; import org.parceler.Parcel; import org.parceler.Parcels; @@ -151,19 +154,21 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr private static final int IMAGE_DESCRIPTION_RESULT=363; private static final int SCHEDULED_STATUS_OPENED_RESULT=161; private static final int MAX_ATTACHMENTS=4; + private static final String GLITCH_LOCAL_ONLY_SUFFIX = "👁"; + private static final Pattern GLITCH_LOCAL_ONLY_PATTERN = Pattern.compile("[\\s\\S]*" + GLITCH_LOCAL_ONLY_SUFFIX + "[\uFE00-\uFE0F]*"); private static final String TAG="ComposeFragment"; - private static final Pattern MENTION_PATTERN=Pattern.compile("(^|[^\\/\\w])@(([a-z0-9_]+)@[a-z0-9\\.\\-]+[a-z0-9]+)", Pattern.CASE_INSENSITIVE); + public static final Pattern MENTION_PATTERN=Pattern.compile("(^|[^\\/\\w])@(([a-z0-9_]+)@[a-z0-9\\.\\-]+[a-z0-9]+)", Pattern.CASE_INSENSITIVE); // from https://github.com/mastodon/mastodon-ios/blob/main/Mastodon/Helper/MastodonRegex.swift - private static final Pattern AUTO_COMPLETE_PATTERN=Pattern.compile("(?openFilePicker()); + if (isPhotoPickerAvailable()) { + PopupMenu attachPopup = new PopupMenu(getContext(), mediaBtn); + attachPopup.inflate(R.menu.attach); + attachPopup.setOnMenuItemClickListener(i -> { + openFilePicker(i.getItemId() == R.id.media); + return true; + }); + UiUtils.enablePopupMenuIcons(getContext(), attachPopup); + mediaBtn.setOnClickListener(v->attachPopup.show()); + mediaBtn.setOnTouchListener(attachPopup.getDragToOpenListener()); + } else { + mediaBtn.setOnClickListener(v -> openFilePicker(false)); + } pollBtn.setOnClickListener(v->togglePoll()); emojiBtn.setOnClickListener(v->emojiKeyboard.toggleKeyboardPopup(mainEditText)); spoilerBtn.setOnClickListener(v->toggleSpoiler()); + + localOnly = savedInstanceState != null ? savedInstanceState.getBoolean("localOnly") : + editingStatus != null ? editingStatus.localOnly : replyTo != null && replyTo.localOnly; + buildVisibilityPopup(visibilityBtn); visibilityBtn.setOnClickListener(v->visibilityPopup.show()); visibilityBtn.setOnTouchListener(visibilityPopup.getDragToOpenListener()); @@ -405,6 +429,7 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr spoilerBg.setDrawableByLayerId(R.id.right_drawable, new SpoilerStripesDrawable()); spoilerEdit.setBackground(spoilerBg); if((savedInstanceState!=null && savedInstanceState.getBoolean("hasSpoiler", false)) || hasSpoiler){ + hasSpoiler=true; spoilerEdit.setVisibility(View.VISIBLE); spoilerBtn.setSelected(true); }else if(editingStatus!=null && !TextUtils.isEmpty(editingStatus.spoilerText)){ @@ -448,7 +473,9 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr case UNLISTED -> R.id.vis_unlisted; case PRIVATE -> R.id.vis_followers; case DIRECT -> R.id.vis_private; + case LOCAL -> R.id.vis_local; }).setChecked(true); + visibilityPopup.getMenu().findItem(R.id.local_only).setChecked(localOnly); autocompleteViewController=new ComposeAutocompleteViewController(getActivity(), accountID); autocompleteViewController.setCompletionSelectedListener(this::onAutocompleteOptionSelected); @@ -475,6 +502,7 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr outState.putBoolean("pollAllowMultiple", pollAllowMultipleItem.isSelected()); } outState.putBoolean("sensitive", sensitive); + outState.putBoolean("localOnly", localOnly); outState.putBoolean("hasSpoiler", hasSpoiler); outState.putString("language", language); if(!attachments.isEmpty()){ @@ -598,6 +626,7 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr } }); View originalPost = view.findViewById(R.id.original_post); + extraText = view.findViewById(R.id.extra_text); originalPost.setVisibility(View.VISIBLE); originalPost.setOnClickListener(v->{ Bundle args=new Bundle(); @@ -630,9 +659,10 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr view.findViewById(R.id.visibility).setVisibility(View.GONE); Drawable visibilityIcon = getActivity().getDrawable(switch(replyTo.visibility){ case PUBLIC -> R.drawable.ic_fluent_earth_20_regular; - case UNLISTED -> R.drawable.ic_fluent_people_community_20_regular; - case PRIVATE -> R.drawable.ic_fluent_people_checkmark_20_regular; + case UNLISTED -> R.drawable.ic_fluent_lock_open_20_regular; + case PRIVATE -> R.drawable.ic_fluent_lock_closed_20_filled; case DIRECT -> R.drawable.ic_fluent_mention_20_regular; + case LOCAL -> R.drawable.ic_fluent_eye_20_regular; }); ImageView moreBtn = view.findViewById(R.id.more); moreBtn.setImageDrawable(visibilityIcon); @@ -656,6 +686,7 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr case UNLISTED -> R.string.sk_visibility_unlisted; case PRIVATE -> R.string.visibility_followers_only; case DIRECT -> R.string.visibility_private; + case LOCAL -> R.string.sk_local_only; }; replyText.setContentDescription(getString(R.string.in_reply_to, replyTo.account.displayName) + ". " + getString(R.string.post_visibility) + ": " + getString(visibilityNameRes)); replyText.setOnClickListener(v->{ @@ -682,7 +713,11 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr if(!TextUtils.isEmpty(replyTo.spoilerText)){ hasSpoiler=true; spoilerEdit.setVisibility(View.VISIBLE); - spoilerEdit.setText(replyTo.spoilerText); + if(GlobalUserPreferences.prefixRepliesWithRe && !replyTo.spoilerText.startsWith("re: ")){ + spoilerEdit.setText("re: " + replyTo.spoilerText); + }else{ + spoilerEdit.setText(replyTo.spoilerText); + } spoilerBtn.setSelected(true); } if (replyTo.language != null && !replyTo.language.isEmpty()) updateLanguage(replyTo.language); @@ -736,6 +771,7 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr } updateSensitive(); + updateHeaders(); if(editingStatus!=null){ updateCharCounter(); @@ -778,6 +814,11 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr draftsBtn.setOnTouchListener(draftOptionsPopup.getDragToOpenListener()); updateScheduledAt(scheduledAt != null ? scheduledAt : scheduledStatus != null ? scheduledStatus.scheduledAt : null); buildLanguageSelector(languageButton); + + if (editingStatus != null && scheduledStatus == null) { + // editing an already published post + draftsBtn.setVisibility(View.GONE); + } } private void navigateToUnsentPosts() { @@ -801,9 +842,13 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr } private void updateLanguage(MastodonLanguage loc) { - language = loc.getLanguage(); - languageButton.setText(loc.getLanguageName()); - languageButton.setContentDescription(getActivity().getString(R.string.sk_post_language, loc.getDefaultName())); + updateLanguage(loc.getLanguage(), loc.getLanguageName(), loc.getDefaultName()); + } + + private void updateLanguage(String languageTag, String languageName, String defaultName) { + language = languageTag; + languageButton.setText(languageName); + languageButton.setContentDescription(getActivity().getString(R.string.sk_post_language, defaultName)); } @SuppressLint("ClickableViewAccessibility") @@ -813,14 +858,19 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr btn.setOnClickListener(v->languagePopup.show()); Preferences prefs = AccountSessionManager.getInstance().getAccount(accountID).preferences; - updateLanguage(prefs != null && prefs.postingDefaultLanguage != null && prefs.postingDefaultLanguage.length() > 0 + if (language != null) updateLanguage(language); + else updateLanguage(prefs != null && prefs.postingDefaultLanguage != null && prefs.postingDefaultLanguage.length() > 0 ? languageResolver.from(prefs.postingDefaultLanguage) : languageResolver.getDefault()); Menu languageMenu = languagePopup.getMenu(); for (String recentLanguage : Optional.ofNullable(recentLanguages.get(accountID)).orElse(defaultRecentLanguages)) { - MastodonLanguage l = languageResolver.from(recentLanguage); - languageMenu.add(0, allLanguages.indexOf(l), Menu.NONE, getActivity().getString(R.string.sk_language_name, l.getDefaultName(), l.getLanguageName())); + if (recentLanguage.equals("bottom")) { + addBottomLanguage(languageMenu); + } else { + MastodonLanguage l = languageResolver.from(recentLanguage); + languageMenu.add(0, allLanguages.indexOf(l), Menu.NONE, getActivity().getString(R.string.sk_language_name, l.getDefaultName(), l.getLanguageName())); + } } SubMenu allLanguagesMenu = languageMenu.addSubMenu(R.string.sk_available_languages); @@ -829,13 +879,33 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr allLanguagesMenu.add(0, i, Menu.NONE, getActivity().getString(R.string.sk_language_name, l.getDefaultName(), l.getLanguageName())); } + if (GlobalUserPreferences.bottomEncoding) addBottomLanguage(allLanguagesMenu); + + btn.setOnLongClickListener(v->{ + btn.performHapticFeedback(HapticFeedbackConstants.LONG_PRESS); + if (!GlobalUserPreferences.bottomEncoding) addBottomLanguage(allLanguagesMenu); + return false; + }); + languagePopup.setOnMenuItemClickListener(i->{ if (i.hasSubMenu()) return false; - updateLanguage(allLanguages.get(i.getItemId())); + if (i.getItemId() == allLanguages.size()) { + updateLanguage(language, "\uD83E\uDD7A\uD83D\uDC49\uD83D\uDC48", "bottom"); + encoding = "bottom"; + } else { + updateLanguage(allLanguages.get(i.getItemId())); + encoding = null; + } return true; }); } + private void addBottomLanguage(Menu menu) { + if (menu.findItem(allLanguages.size()) == null) { + menu.add(0, allLanguages.size(), Menu.NONE, "bottom (\uD83E\uDD7A\uD83D\uDC49\uD83D\uDC48)"); + } + } + @Override public boolean onOptionsItemSelected(MenuItem item){ return true; @@ -865,6 +935,9 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr if(hasSpoiler){ charCount+=spoilerEdit.length(); } + if (localOnly && GlobalUserPreferences.accountsInGlitchMode.contains(accountID)) { + charCount -= GLITCH_LOCAL_ONLY_SUFFIX.length(); + } charCounter.setText(String.valueOf(charLimit-charCount)); trimmedCharCount=text.toString().trim().length(); updatePublishButtonState(); @@ -951,15 +1024,53 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr } private void publish(){ + publish(false); + } + + private void publish(boolean force){ String text=mainEditText.getText().toString(); CreateStatus.Request req=new CreateStatus.Request(); + if ("bottom".equals(encoding)) { + text = new StatusTextEncoder(Bottom::encode).encode(text); + req.spoilerText = "bottom-encoded emoji spam"; + } + if (localOnly && + GlobalUserPreferences.accountsInGlitchMode.contains(accountID) && + !GLITCH_LOCAL_ONLY_PATTERN.matcher(text).matches()) { + text += " " + GLITCH_LOCAL_ONLY_SUFFIX; + } req.status=text; - req.visibility=statusVisibility; + req.localOnly=localOnly; + req.visibility=localOnly && instance.pleroma != null ? StatusPrivacy.LOCAL : statusVisibility; req.sensitive=sensitive; req.language=language; req.scheduledAt = scheduledAt; if(!attachments.isEmpty()){ req.mediaIds=attachments.stream().map(a->a.serverAttachment.id).collect(Collectors.toList()); + Optional withoutAltText = attachments.stream().filter(a -> a.description == null || a.description.isBlank()).findFirst(); + boolean isDraft = scheduledAt != null && scheduledAt.isAfter(DRAFTS_AFTER_INSTANT); + if (!force && !GlobalUserPreferences.disableAltTextReminder && !isDraft && withoutAltText.isPresent()) { + new M3AlertDialogBuilder(getActivity()) + .setTitle(R.string.sk_alt_text_missing_title) + .setMessage(R.string.sk_alt_text_missing) + .setPositiveButton(R.string.add_alt_text, (d, w) -> editMediaDescription(withoutAltText.get())) + .setNegativeButton(R.string.sk_publish_anyway, (d, w) -> publish(true)) + .show(); + return; + } + } + // ask whether to publish now when editing an existing draft + if (!force && editingStatus != null && scheduledAt != null && scheduledAt.isAfter(DRAFTS_AFTER_INSTANT)) { + new M3AlertDialogBuilder(getActivity()) + .setTitle(R.string.sk_save_draft) + .setMessage(R.string.sk_save_draft_message) + .setPositiveButton(R.string.save, (d, w) -> publish(true)) + .setNegativeButton(R.string.publish, (d, w) -> { + updateScheduledAt(null); + publish(); + }) + .show(); + return; } if(replyTo!=null || (editingStatus != null && editingStatus.inReplyToId!=null)){ req.inReplyToId=editingStatus!=null ? editingStatus.inReplyToId : replyTo.id; @@ -1064,6 +1175,14 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr List newRecentLanguages = new ArrayList<>(Optional.ofNullable(recentLanguages.get(accountID)).orElse(defaultRecentLanguages)); newRecentLanguages.remove(language); newRecentLanguages.add(0, language); + if (encoding != null) { + newRecentLanguages.remove(encoding); + newRecentLanguages.add(0, encoding); + } + if ("bottom".equals(encoding) && !GlobalUserPreferences.bottomEncoding) { + GlobalUserPreferences.bottomEncoding = true; + GlobalUserPreferences.save(); + } recentLanguages.put(accountID, newRecentLanguages.stream().limit(4).collect(Collectors.toList())); GlobalUserPreferences.save(); } @@ -1129,7 +1248,14 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr } private void confirmDiscardDraftAndFinish(){ - new M3AlertDialogBuilder(getActivity()) + boolean attachmentsPending = attachments.stream().anyMatch(att -> att.state != AttachmentUploadState.DONE); + if (attachmentsPending) new M3AlertDialogBuilder(getActivity()) + .setTitle(R.string.sk_unfinished_attachments) + .setMessage(R.string.sk_unfinished_attachments_message) + .setPositiveButton(R.string.edit, (d, w) -> {}) + .setNegativeButton(R.string.discard, (d, w) -> Nav.finish(this)) + .show(); + else new M3AlertDialogBuilder(getActivity()) .setTitle(editingStatus != null ? R.string.sk_confirm_save_changes : R.string.sk_confirm_save_draft) .setPositiveButton(R.string.save, (d, w) -> { updateScheduledAt(scheduledAt == null ? getDraftInstant() : scheduledAt); @@ -1139,18 +1265,6 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr .show(); } - /** - * Check to see if Android platform photopicker is available on the device\ - * @return whether the device supports photopicker intents. - */ - private boolean isPhotoPickerAvailable() { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - return true; - } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { - return getExtensionVersion(Build.VERSION_CODES.R) >= 2; - } else - return false; - } /** * Builds the correct intent for the device version to select media. @@ -1160,26 +1274,26 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr * *

For earlier versions use the built in docs ui via {@link Intent#ACTION_GET_CONTENT} */ - private void openFilePicker(){ + private void openFilePicker(boolean photoPicker){ Intent intent; - boolean usePhotoPicker = isPhotoPickerAvailable(); - if (usePhotoPicker) { - intent = new Intent(MediaStore.ACTION_PICK_IMAGES); - intent.putExtra(MediaStore.EXTRA_PICK_IMAGES_MAX, MediaStore.getPickImagesMaxLimit()); - } else { - intent = new Intent(Intent.ACTION_GET_CONTENT); + boolean usePhotoPicker=photoPicker && isPhotoPickerAvailable(); + if(usePhotoPicker){ + intent=new Intent(MediaStore.ACTION_PICK_IMAGES); + intent.putExtra(MediaStore.EXTRA_PICK_IMAGES_MAX, MAX_ATTACHMENTS-getMediaAttachmentsCount()); + }else{ + intent=new Intent(Intent.ACTION_GET_CONTENT); intent.addCategory(Intent.CATEGORY_OPENABLE); intent.setType("*/*"); } - if (!usePhotoPicker && instance.configuration != null && - instance.configuration.mediaAttachments != null && - instance.configuration.mediaAttachments.supportedMimeTypes != null && - !instance.configuration.mediaAttachments.supportedMimeTypes.isEmpty()) { + if(!usePhotoPicker && instance.configuration!=null && + instance.configuration.mediaAttachments!=null && + instance.configuration.mediaAttachments.supportedMimeTypes!=null && + !instance.configuration.mediaAttachments.supportedMimeTypes.isEmpty()){ intent.putExtra(Intent.EXTRA_MIME_TYPES, instance.configuration.mediaAttachments.supportedMimeTypes.toArray( new String[0])); - } else { - if (!usePhotoPicker) { + }else{ + if(!usePhotoPicker){ // If photo picker is being used these are the default mimetypes. intent.putExtra(Intent.EXTRA_MIME_TYPES, new String[]{"image/*", "video/*"}); } @@ -1524,6 +1638,10 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr DraftMediaAttachment att=(DraftMediaAttachment) v.getTag(); if(att.serverAttachment==null) return; + editMediaDescription(att); + } + + private void editMediaDescription(DraftMediaAttachment att) { Bundle args=new Bundle(); args.putString("account", accountID); args.putString("attachment", att.serverAttachment.id); @@ -1600,18 +1718,20 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr menu.getMenu().add(0, 2, 0, getResources().getQuantityString(R.plurals.x_minutes, 30, 30)); menu.getMenu().add(0, 3, 0, getResources().getQuantityString(R.plurals.x_hours, 1, 1)); menu.getMenu().add(0, 4, 0, getResources().getQuantityString(R.plurals.x_hours, 6, 6)); - menu.getMenu().add(0, 5, 0, getResources().getQuantityString(R.plurals.x_days, 1, 1)); - menu.getMenu().add(0, 6, 0, getResources().getQuantityString(R.plurals.x_days, 3, 3)); - menu.getMenu().add(0, 7, 0, getResources().getQuantityString(R.plurals.x_days, 7, 7)); + menu.getMenu().add(0, 5, 0, getResources().getQuantityString(R.plurals.x_hours, 12, 12)); + menu.getMenu().add(0, 6, 0, getResources().getQuantityString(R.plurals.x_days, 1, 1)); + menu.getMenu().add(0, 7, 0, getResources().getQuantityString(R.plurals.x_days, 3, 3)); + menu.getMenu().add(0, 8, 0, getResources().getQuantityString(R.plurals.x_days, 7, 7)); menu.setOnMenuItemClickListener(item->{ pollDuration=switch(item.getItemId()){ case 1 -> 5*60; case 2 -> 30*60; case 3 -> 3600; case 4 -> 6*3600; - case 5 -> 24*3600; - case 6 -> 3*24*3600; - case 7 -> 7*24*3600; + case 5 -> 12*3600; + case 6 -> 24*3600; + case 7 -> 3*24*3600; + case 8 -> 7*24*3600; default -> throw new IllegalStateException("Unexpected value: "+item.getItemId()); }; pollDurationView.setText(getString(R.string.compose_poll_duration, pollDurationStr=item.getTitle().toString())); @@ -1705,12 +1825,33 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr return attachments.size(); } + private void updateHeaders() { + UiUtils.setExtraTextInfo(getContext(), selfExtraText, statusVisibility, localOnly); + if (replyTo != null) UiUtils.setExtraTextInfo(getContext(), extraText, replyTo.visibility, replyTo.localOnly); + } + private void buildVisibilityPopup(View v){ visibilityPopup=new PopupMenu(getActivity(), v); visibilityPopup.inflate(R.menu.compose_visibility); Menu m=visibilityPopup.getMenu(); + MenuItem localOnlyItem = visibilityPopup.getMenu().findItem(R.id.local_only); + boolean prefsSaysSupported = GlobalUserPreferences.accountsWithLocalOnlySupport.contains(accountID); + if (instance.pleroma != null) { + m.findItem(R.id.vis_local).setVisible(true); + } else if (localOnly || prefsSaysSupported) { + localOnlyItem.setVisible(true); + localOnlyItem.setChecked(localOnly); + Status status = editingStatus != null ? editingStatus : replyTo; + if (!prefsSaysSupported) { + GlobalUserPreferences.accountsWithLocalOnlySupport.add(accountID); + if (GLITCH_LOCAL_ONLY_PATTERN.matcher(status.getStrippedText()).matches()) { + GlobalUserPreferences.accountsInGlitchMode.add(accountID); + } + GlobalUserPreferences.save(); + } + } UiUtils.enablePopupMenuIcons(getActivity(), visibilityPopup); - m.setGroupCheckable(0, true, true); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) m.setGroupDividerEnabled(true); visibilityPopup.setOnMenuItemClickListener(new PopupMenu.OnMenuItemClickListener(){ @Override public boolean onMenuItemClick(MenuItem item){ @@ -1723,41 +1864,44 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr statusVisibility=StatusPrivacy.PRIVATE; }else if(id==R.id.vis_private){ statusVisibility=StatusPrivacy.DIRECT; + }else if(id==R.id.vis_local){ + statusVisibility=StatusPrivacy.LOCAL; + } + if (id == R.id.local_only) { + localOnly = !item.isChecked(); + item.setChecked(localOnly); + } else { + item.setChecked(true); } - item.setChecked(true); updateVisibilityIcon(); + updateHeaders(); return true; } }); } private void loadDefaultStatusVisibility(Bundle savedInstanceState) { - if(getArguments().containsKey("replyTo")){ - replyTo=Parcels.unwrap(getArguments().getParcelable("replyTo")); - statusVisibility = replyTo.visibility; - } + if(replyTo != null) statusVisibility = replyTo.visibility; // A saved privacy setting from a previous compose session wins over the reply visibility if(savedInstanceState !=null){ statusVisibility = (StatusPrivacy) savedInstanceState.getSerializable("visibility"); } - Preferences prefs = AccountSessionManager.getInstance().getAccount(accountID).preferences; + AccountSessionManager asm = AccountSessionManager.getInstance(); + Preferences prefs = asm.getAccount(accountID).preferences; if (prefs != null) { // Only override the reply visibility if our preference is more private - if (prefs.postingDefaultVisibility.isLessVisibleThan(statusVisibility)) { - statusVisibility = switch (prefs.postingDefaultVisibility) { - case PUBLIC -> StatusPrivacy.PUBLIC; - case UNLISTED -> StatusPrivacy.UNLISTED; - case PRIVATE -> StatusPrivacy.PRIVATE; - case DIRECT -> StatusPrivacy.DIRECT; - }; + // (and we're not replying to ourselves, or not at all) + if (prefs.postingDefaultVisibility.isLessVisibleThan(statusVisibility) && + (replyTo == null || !asm.isSelf(accountID, replyTo.account))) { + statusVisibility = prefs.postingDefaultVisibility; } + } - // A saved privacy setting from a previous compose session wins over all - if(savedInstanceState !=null){ - statusVisibility = (StatusPrivacy) savedInstanceState.getSerializable("visibility"); - } + // A saved privacy setting from a previous compose session wins over all + if(savedInstanceState !=null){ + statusVisibility = (StatusPrivacy) savedInstanceState.getSerializable("visibility"); } } @@ -1767,9 +1911,10 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr } visibilityBtn.setImageResource(switch(statusVisibility){ case PUBLIC -> R.drawable.ic_fluent_earth_24_regular; - case UNLISTED -> R.drawable.ic_fluent_people_community_24_regular; - case PRIVATE -> R.drawable.ic_fluent_people_checkmark_24_regular; + case UNLISTED -> R.drawable.ic_fluent_lock_open_24_regular; + case PRIVATE -> R.drawable.ic_fluent_lock_closed_24_filled; case DIRECT -> R.drawable.ic_fluent_mention_24_regular; + case LOCAL -> R.drawable.ic_fluent_eye_24_regular; }); } diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/EditTimelinesFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/EditTimelinesFragment.java new file mode 100644 index 000000000..c947b0fa9 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/EditTimelinesFragment.java @@ -0,0 +1,352 @@ +package org.joinmastodon.android.fragments; + +import static android.view.Menu.NONE; + +import static org.joinmastodon.android.ui.utils.UiUtils.makeBackItem; + +import android.annotation.SuppressLint; +import android.content.Context; +import android.os.Bundle; +import android.view.Menu; +import android.view.MenuInflater; +import android.view.MenuItem; +import android.view.MotionEvent; +import android.view.SubMenu; +import android.view.View; +import android.view.ViewGroup; +import android.widget.EditText; +import android.widget.ImageButton; +import android.widget.ImageView; +import android.widget.LinearLayout; +import android.widget.PopupMenu; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.recyclerview.widget.ItemTouchHelper; +import androidx.recyclerview.widget.RecyclerView; + +import org.joinmastodon.android.GlobalUserPreferences; +import org.joinmastodon.android.R; +import org.joinmastodon.android.api.requests.lists.GetLists; +import org.joinmastodon.android.api.requests.tags.GetFollowedHashtags; +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; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import me.grishka.appkit.api.Callback; +import me.grishka.appkit.api.ErrorResponse; +import me.grishka.appkit.fragments.BaseRecyclerFragment; +import me.grishka.appkit.utils.BindableViewHolder; +import me.grishka.appkit.views.UsableRecyclerView; + +public class EditTimelinesFragment extends BaseRecyclerFragment implements ScrollableToTop { + private String accountID; + private TimelinesAdapter adapter; + private final ItemTouchHelper itemTouchHelper; + private Menu optionsMenu; + private boolean updated; + private final Map timelineByMenuItem = new HashMap<>(); + private final List listTimelines = new ArrayList<>(); + private final List hashtags = new ArrayList<>(); + + public EditTimelinesFragment() { + super(10); + ItemTouchHelper.SimpleCallback itemTouchCallback = new ItemTouchHelperCallback() ; + itemTouchHelper = new ItemTouchHelper(itemTouchCallback); + } + + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setHasOptionsMenu(true); + setTitle(R.string.sk_timelines); + accountID = getArguments().getString("account"); + + new GetLists().setCallback(new Callback<>() { + @Override + public void onSuccess(List result) { + listTimelines.addAll(result); + updateOptionsMenu(); + } + + @Override + public void onError(ErrorResponse error) { + error.showToast(getContext()); + } + }).exec(accountID); + + new GetFollowedHashtags().setCallback(new Callback<>() { + @Override + public void onSuccess(HeaderPaginationList result) { + hashtags.addAll(result); + updateOptionsMenu(); + } + + @Override + public void onError(ErrorResponse error) { + error.showToast(getContext()); + } + }).exec(accountID); + } + + @Override + protected void onShown(){ + super.onShown(); + if(!getArguments().getBoolean("noAutoLoad") && !loaded && !dataLoading) loadData(); + } + + @Override + public void onViewCreated(View view, Bundle savedInstanceState) { + super.onViewCreated(view, savedInstanceState); + itemTouchHelper.attachToRecyclerView(list); + refreshLayout.setEnabled(false); + list.addItemDecoration(new DividerItemDecoration(getActivity(), R.attr.colorPollVoted, 0.5f, 56, 16)); + } + + @Override + public void onCreateOptionsMenu(Menu menu, MenuInflater inflater) { + this.optionsMenu = menu; + updateOptionsMenu(); + } + + @Override + public boolean onOptionsItemSelected(MenuItem item) { + if (item.getItemId() == R.id.menu_back) { + updateOptionsMenu(); + optionsMenu.performIdentifierAction(R.id.menu_add_timeline, 0); + return true; + } + TimelineDefinition tl = timelineByMenuItem.get(item); + if (tl != null) { + data.add(tl.copy()); + adapter.notifyItemInserted(data.size()); + saveTimelines(); + updateOptionsMenu(); + }; + return true; + } + + private void addTimelineToOptions(TimelineDefinition tl, Menu menu) { + if (data.contains(tl)) return; + MenuItem item = menu.add(0, View.generateViewId(), Menu.NONE, tl.getTitle(getContext())); + item.setIcon(tl.getIcon().iconRes); + timelineByMenuItem.put(item, tl); + } + + private void updateOptionsMenu() { + if (getActivity() == null) return; + optionsMenu.clear(); + timelineByMenuItem.clear(); + + SubMenu menu = optionsMenu.addSubMenu(0, R.id.menu_add_timeline, NONE, R.string.sk_timelines_add); + menu.getItem().setShowAsAction(MenuItem.SHOW_AS_ACTION_ALWAYS); + menu.getItem().setIcon(R.drawable.ic_fluent_add_24_regular); + + SubMenu timelinesMenu = menu.addSubMenu(R.string.sk_timeline); + timelinesMenu.getItem().setIcon(R.drawable.ic_fluent_timeline_24_regular); + SubMenu listsMenu = menu.addSubMenu(R.string.sk_list); + listsMenu.getItem().setIcon(R.drawable.ic_fluent_people_24_regular); + SubMenu hashtagsMenu = menu.addSubMenu(R.string.sk_hashtag); + hashtagsMenu.getItem().setIcon(R.drawable.ic_fluent_number_symbol_24_regular); + + makeBackItem(timelinesMenu); + makeBackItem(listsMenu); + makeBackItem(hashtagsMenu); + + TimelineDefinition.ALL_TIMELINES.forEach(tl -> addTimelineToOptions(tl, timelinesMenu)); + listTimelines.stream().map(TimelineDefinition::ofList).forEach(tl -> addTimelineToOptions(tl, listsMenu)); + hashtags.stream().map(TimelineDefinition::ofHashtag).forEach(tl -> addTimelineToOptions(tl, hashtagsMenu)); + + timelinesMenu.getItem().setVisible(timelinesMenu.size() > 0); + listsMenu.getItem().setVisible(listsMenu.size() > 0); + hashtagsMenu.getItem().setVisible(hashtagsMenu.size() > 0); + + UiUtils.enableOptionsMenuIcons(getContext(), optionsMenu, R.id.menu_add_timeline); + } + + private void saveTimelines() { + updated = true; + GlobalUserPreferences.pinnedTimelines.put(accountID, data.size() > 0 ? data : List.of(TimelineDefinition.HOME_TIMELINE)); + GlobalUserPreferences.save(); + } + + private void removeTimeline(int position) { + data.remove(position); + adapter.notifyItemRemoved(position); + saveTimelines(); + updateOptionsMenu(); + } + + @Override + protected void doLoadData(int offset, int count){ + onDataLoaded(GlobalUserPreferences.pinnedTimelines.getOrDefault(accountID, TimelineDefinition.DEFAULT_TIMELINES), false); + updateOptionsMenu(); + } + + @Override + protected RecyclerView.Adapter getAdapter() { + return adapter = new TimelinesAdapter(); + } + + @Override + public void scrollToTop() { + smoothScrollRecyclerViewToTop(list); + } + + @Override + public void onDestroy() { + super.onDestroy(); + if (updated) UiUtils.restartApp(); + } + + private class TimelinesAdapter extends RecyclerView.Adapter{ + @NonNull + @Override + public TimelineViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType){ + return new TimelineViewHolder(); + } + + @Override + public void onBindViewHolder(@NonNull TimelineViewHolder holder, int position) { + holder.bind(data.get(position)); + } + + @Override + public int getItemCount() { + return data.size(); + } + } + + private class TimelineViewHolder extends BindableViewHolder implements UsableRecyclerView.Clickable{ + private final TextView title; + private final ImageView dragger; + + public TimelineViewHolder(){ + super(getActivity(), R.layout.item_text, list); + title=findViewById(R.id.title); + dragger=findViewById(R.id.dragger_thingy); + } + + @SuppressLint("ClickableViewAccessibility") + @Override + public void onBind(TimelineDefinition item) { + title.setText(item.getTitle(getContext())); + title.setCompoundDrawablesRelativeWithIntrinsicBounds(itemView.getContext().getDrawable(item.getIcon().iconRes), null, null, null); + dragger.setVisibility(View.VISIBLE); + dragger.setOnTouchListener((View v, MotionEvent event) -> { + if (event.getAction() == MotionEvent.ACTION_DOWN) { + itemTouchHelper.startDrag(this); + return true; + } + return false; + }); + } + + @SuppressLint("ClickableViewAccessibility") + @Override + public void onClick() { + Context ctx = getContext(); + LinearLayout view = (LinearLayout) getActivity().getLayoutInflater() + .inflate(R.layout.edit_timeline, (ViewGroup) itemView, false); + + TextInputFrameLayout inputLayout = view.findViewById(R.id.input); + EditText editText = inputLayout.getEditText(); + editText.setText(item.getCustomTitle()); + editText.setHint(item.getDefaultTitle(ctx)); + + ImageButton btn = view.findViewById(R.id.button); + PopupMenu popup = new PopupMenu(ctx, btn); + TimelineDefinition.Icon currentIcon = item.getIcon(); + btn.setImageResource(currentIcon.iconRes); + btn.setContentDescription(ctx.getString(currentIcon.nameRes)); + btn.setOnTouchListener(popup.getDragToOpenListener()); + btn.setOnClickListener(l -> popup.show()); + + Menu menu = popup.getMenu(); + TimelineDefinition.Icon defaultIcon = item.getDefaultIcon(); + menu.add(0, currentIcon.ordinal(), NONE, currentIcon.nameRes).setIcon(currentIcon.iconRes); + if (!currentIcon.equals(defaultIcon)) { + menu.add(0, defaultIcon.ordinal(), NONE, defaultIcon.nameRes).setIcon(defaultIcon.iconRes); + } + for (TimelineDefinition.Icon icon : TimelineDefinition.Icon.values()) { + if (icon.hidden || icon.equals(item.getIcon())) continue; + menu.add(0, icon.ordinal(), NONE, icon.nameRes).setIcon(icon.iconRes); + } + UiUtils.enablePopupMenuIcons(ctx, popup); + + popup.setOnMenuItemClickListener(menuItem -> { + TimelineDefinition.Icon icon = TimelineDefinition.Icon.values()[menuItem.getItemId()]; + btn.setImageResource(icon.iconRes); + btn.setContentDescription(ctx.getString(icon.nameRes)); + item.setIcon(icon); + return true; + }); + + new M3AlertDialogBuilder(ctx) + .setTitle(R.string.sk_edit_timeline) + .setView(view) + .setPositiveButton(R.string.save, (d, which) -> { + item.setTitle(editText.getText().toString().trim()); + rebind(); + saveTimelines(); + }) + .setNeutralButton(R.string.sk_remove, (d, which) -> + removeTimeline(getAbsoluteAdapterPosition())) + .setNegativeButton(R.string.cancel, (d, which) -> {}) + .show(); + + btn.requestFocus(); + } + } + + private class ItemTouchHelperCallback extends ItemTouchHelper.SimpleCallback { + public ItemTouchHelperCallback() { + super(ItemTouchHelper.UP | ItemTouchHelper.DOWN, ItemTouchHelper.LEFT | ItemTouchHelper.RIGHT); + } + + @Override + public boolean onMove(@NonNull RecyclerView recyclerView, @NonNull RecyclerView.ViewHolder viewHolder, @NonNull RecyclerView.ViewHolder target) { + int fromPosition = viewHolder.getAbsoluteAdapterPosition(); + int toPosition = target.getAbsoluteAdapterPosition(); + if (Math.max(fromPosition, toPosition) >= data.size() || Math.min(fromPosition, toPosition) < 0) { + return false; + } else { + Collections.swap(data, fromPosition, toPosition); + adapter.notifyItemMoved(fromPosition, toPosition); + saveTimelines(); + return true; + } + } + + @Override + public void onSelectedChanged(@Nullable RecyclerView.ViewHolder viewHolder, int actionState) { + if (actionState == ItemTouchHelper.ACTION_STATE_DRAG && viewHolder != null) { + viewHolder.itemView.animate().alpha(0.65f); + } + } + + @Override + public void clearView(@NonNull RecyclerView recyclerView, @NonNull RecyclerView.ViewHolder viewHolder) { + super.clearView(recyclerView, viewHolder); + viewHolder.itemView.animate().alpha(1f); + } + + @Override + public void onSwiped(@NonNull RecyclerView.ViewHolder viewHolder, int direction) { + int position = viewHolder.getAbsoluteAdapterPosition(); + removeTimeline(position); + } + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/FabStatusListFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/FabStatusListFragment.java deleted file mode 100644 index c4443a2c8..000000000 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/FabStatusListFragment.java +++ /dev/null @@ -1,37 +0,0 @@ -package org.joinmastodon.android.fragments; - -import android.content.res.Configuration; -import android.os.Bundle; -import android.view.View; -import android.widget.ImageButton; - -import org.joinmastodon.android.R; -import org.joinmastodon.android.ui.utils.UiUtils; - -import me.grishka.appkit.Nav; - -public abstract class FabStatusListFragment extends StatusListFragment { - protected ImageButton fab; - - public FabStatusListFragment() { - setListLayoutId(R.layout.recycler_fragment_with_fab); - } - - @Override - public void onViewCreated(View view, Bundle savedInstanceState) { - super.onViewCreated(view, savedInstanceState); - fab = view.findViewById(R.id.fab); - fab.setOnClickListener(this::onFabClick); - fab.setOnLongClickListener(this::onFabLongClick); - } - - protected void onFabClick(View v){ - Bundle args=new Bundle(); - args.putString("account", accountID); - Nav.go(getActivity(), ComposeFragment.class, args); - } - - protected boolean onFabLongClick(View v) { - return UiUtils.pickAccountForCompose(getActivity(), accountID); - } -} diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/FavoritedStatusListFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/FavoritedStatusListFragment.java index f0779e71a..31fdd8442 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/FavoritedStatusListFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/FavoritedStatusListFragment.java @@ -25,6 +25,7 @@ public class FavoritedStatusListFragment extends StatusListFragment{ .setCallback(new SimpleCallback<>(this){ @Override public void onSuccess(HeaderPaginationList result){ + if (getActivity() == null) return; if(result.nextPageUri!=null) nextMaxID=result.nextPageUri.getQueryParameter("max_id"); else diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/FollowRequestsListFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/FollowRequestsListFragment.java index 8f2b35fcf..76b28334f 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/FollowRequestsListFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/FollowRequestsListFragment.java @@ -80,6 +80,7 @@ public class FollowRequestsListFragment extends BaseRecyclerFragment(this){ @Override public void onSuccess(HeaderPaginationList result){ + if (getActivity() == null) return; if(result.nextPageUri!=null) nextMaxID=result.nextPageUri.getQueryParameter("max_id"); else diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/FollowedHashtagsFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/FollowedHashtagsFragment.java index 1a9ecb862..cdb729841 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/FollowedHashtagsFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/FollowedHashtagsFragment.java @@ -55,6 +55,7 @@ public class FollowedHashtagsFragment extends BaseRecyclerFragment impl .setCallback(new SimpleCallback<>(this){ @Override public void onSuccess(HeaderPaginationList result){ + if (getActivity() == null) return; if(result.nextPageUri!=null) nextMaxID=result.nextPageUri.getQueryParameter("max_id"); else 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 74c3185ed..ce95a9731 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/HashtagTimelineFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/HashtagTimelineFragment.java @@ -2,6 +2,7 @@ package org.joinmastodon.android.fragments; import android.app.Activity; import android.os.Bundle; +import android.view.HapticFeedbackConstants; import android.view.Menu; import android.view.MenuInflater; import android.view.MenuItem; @@ -10,15 +11,21 @@ import android.view.ViewGroup; import android.widget.ImageButton; import android.widget.Toast; +import org.joinmastodon.android.E; import org.joinmastodon.android.R; import org.joinmastodon.android.api.requests.tags.GetHashtag; import org.joinmastodon.android.api.requests.tags.SetHashtagFollowed; import org.joinmastodon.android.api.requests.timelines.GetHashtagTimeline; +import org.joinmastodon.android.events.HashtagUpdatedEvent; +import org.joinmastodon.android.model.Filter; import org.joinmastodon.android.model.Hashtag; import org.joinmastodon.android.model.Status; +import org.joinmastodon.android.model.TimelineDefinition; import org.joinmastodon.android.ui.utils.UiUtils; +import org.joinmastodon.android.utils.StatusFilterPredicate; import java.util.List; +import java.util.stream.Collectors; import me.grishka.appkit.Nav; import me.grishka.appkit.api.Callback; @@ -26,14 +33,14 @@ import me.grishka.appkit.api.ErrorResponse; import me.grishka.appkit.api.SimpleCallback; import me.grishka.appkit.utils.V; -public class HashtagTimelineFragment extends StatusListFragment{ +public class HashtagTimelineFragment extends PinnableStatusListFragment { private String hashtag; private boolean following; - private ImageButton fab; private MenuItem followButton; - public HashtagTimelineFragment(){ - setListLayoutId(R.layout.recycler_fragment_with_fab); + @Override + protected boolean withComposeButton() { + return true; } @Override @@ -41,7 +48,6 @@ public class HashtagTimelineFragment extends StatusListFragment{ super.onAttach(activity); updateTitle(getArguments().getString("hashtag")); following=getArguments().getBoolean("following", false); - setHasOptionsMenu(true); } @@ -54,35 +60,20 @@ public class HashtagTimelineFragment extends StatusListFragment{ this.following = newFollowing; followButton.setTitle(getString(newFollowing ? R.string.unfollow_user : R.string.follow_user, "#" + hashtag)); followButton.setIcon(newFollowing ? R.drawable.ic_fluent_person_delete_24_filled : R.drawable.ic_fluent_person_add_24_regular); + E.post(new HashtagUpdatedEvent(hashtag, following)); } @Override public void onCreateOptionsMenu(Menu menu, MenuInflater inflater) { inflater.inflate(R.menu.hashtag_timeline, menu); + super.onCreateOptionsMenu(menu, inflater); followButton = menu.findItem(R.id.follow_hashtag); updateFollowingState(following); - followButton.setOnMenuItemClickListener(i -> { - updateFollowingState(!following); - new SetHashtagFollowed(hashtag, following).setCallback(new Callback<>() { - @Override - public void onSuccess(Hashtag i) { - if (i.following == following) Toast.makeText(getActivity(), getString(i.following ? R.string.followed_user : R.string.unfollowed_user, "#" + i.name), Toast.LENGTH_SHORT).show(); - updateFollowingState(i.following); - } - - @Override - public void onError(ErrorResponse error) { - error.showToast(getActivity()); - updateFollowingState(!following); - } - }).exec(accountID); - return true; - }); - new GetHashtag(hashtag).setCallback(new Callback<>() { @Override public void onSuccess(Hashtag hashtag) { + if (getActivity() == null) return; updateTitle(hashtag.name); updateFollowingState(hashtag.following); } @@ -94,12 +85,45 @@ public class HashtagTimelineFragment extends StatusListFragment{ }).exec(accountID); } + + @Override + public boolean onOptionsItemSelected(MenuItem item) { + if (super.onOptionsItemSelected(item)) return true; + if (item.getItemId() == R.id.follow_hashtag) { + updateFollowingState(!following); + getToolbar().performHapticFeedback(HapticFeedbackConstants.CONTEXT_CLICK); + new SetHashtagFollowed(hashtag, following).setCallback(new Callback<>() { + @Override + public void onSuccess(Hashtag i) { + if (getActivity() == null) return; + if (i.following == following) Toast.makeText(getActivity(), getString(i.following ? R.string.followed_user : R.string.unfollowed_user, "#" + i.name), Toast.LENGTH_SHORT).show(); + updateFollowingState(i.following); + } + + @Override + public void onError(ErrorResponse error) { + error.showToast(getActivity()); + updateFollowingState(!following); + } + }).exec(accountID); + return true; + } + return false; + } + + @Override + protected TimelineDefinition makeTimelineDefinition() { + return TimelineDefinition.ofHashtag(hashtag); + } + @Override protected void doLoadData(int offset, int count){ currentRequest=new GetHashtagTimeline(hashtag, offset==0 ? null : getMaxID(), null, count) .setCallback(new SimpleCallback<>(this){ @Override public void onSuccess(List result){ + if (getActivity() == null) return; + result=result.stream().filter(new StatusFilterPredicate(accountID, Filter.FilterContext.PUBLIC)).collect(Collectors.toList()); onDataLoaded(result, !result.isEmpty()); } }) @@ -114,14 +138,12 @@ public class HashtagTimelineFragment extends StatusListFragment{ } @Override - public void onViewCreated(View view, Bundle savedInstanceState){ - super.onViewCreated(view, savedInstanceState); - fab=view.findViewById(R.id.fab); - fab.setOnClickListener(this::onFabClick); - fab.setOnLongClickListener(v -> UiUtils.pickAccountForCompose(getActivity(), accountID, '#'+hashtag+' ')); + protected boolean onFabLongClick(View v) { + return UiUtils.pickAccountForCompose(getActivity(), accountID, '#'+hashtag+' '); } - private void onFabClick(View v){ + @Override + protected void onFabClick(View v){ Bundle args=new Bundle(); args.putString("account", accountID); args.putString("prefilledText", '#'+hashtag+' '); 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 7ff7b536b..323fa60f5 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/HomeFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/HomeFragment.java @@ -16,12 +16,10 @@ import android.widget.FrameLayout; import android.widget.ImageView; import android.widget.LinearLayout; -import org.joinmastodon.android.PushNotificationReceiver; import org.joinmastodon.android.R; import org.joinmastodon.android.api.session.AccountSession; import org.joinmastodon.android.api.session.AccountSessionManager; import org.joinmastodon.android.fragments.discover.DiscoverFragment; -import org.joinmastodon.android.fragments.discover.SearchFragment; import org.joinmastodon.android.model.Account; import org.joinmastodon.android.ui.AccountSwitcherSheet; import org.joinmastodon.android.ui.utils.UiUtils; 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 7f7dfcb79..37ce0e002 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/HomeTabFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/HomeTabFragment.java @@ -1,5 +1,7 @@ package org.joinmastodon.android.fragments; +import static org.joinmastodon.android.GlobalUserPreferences.reduceMotion; + import android.animation.Animator; import android.animation.AnimatorListenerAdapter; import android.animation.AnimatorSet; @@ -39,21 +41,26 @@ import org.joinmastodon.android.R; import org.joinmastodon.android.api.requests.announcements.GetAnnouncements; import org.joinmastodon.android.api.requests.lists.GetLists; import org.joinmastodon.android.api.requests.tags.GetFollowedHashtags; +import org.joinmastodon.android.events.HashtagUpdatedEvent; +import org.joinmastodon.android.events.ListDeletedEvent; +import org.joinmastodon.android.events.ListUpdatedCreatedEvent; import org.joinmastodon.android.events.SelfUpdateStateChangedEvent; -import org.joinmastodon.android.fragments.discover.FederatedTimelineFragment; -import org.joinmastodon.android.fragments.discover.LocalTimelineFragment; 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.TimelineDefinition; import org.joinmastodon.android.ui.SimpleViewHolder; import org.joinmastodon.android.ui.utils.UiUtils; import org.joinmastodon.android.updater.GithubSelfUpdater; -import java.util.ArrayList; +import java.util.Collection; import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.Optional; +import java.util.function.Predicate; +import java.util.function.Supplier; import me.grishka.appkit.Nav; import me.grishka.appkit.api.Callback; @@ -67,14 +74,12 @@ public class HomeTabFragment extends MastodonToolbarFragment implements Scrollab private static final int ANNOUNCEMENTS_RESULT = 654; private String accountID; - private MenuItem announcements; + private MenuItem announcements, announcementsAction, settings, settingsAction; // private ImageView toolbarLogo; private Button toolbarShowNewPostsBtn; private boolean newPostsBtnShown; private AnimatorSet currentNewPostsAnim; private ViewPager2 pager; - private final List fragments = new ArrayList<>(); - private final List tabViews = new ArrayList<>(); private View switcher; private FrameLayout toolbarFrame; private ImageView timelineIcon; @@ -83,11 +88,29 @@ public class HomeTabFragment extends MastodonToolbarFragment implements Scrollab private PopupMenu switcherPopup; private final Map listItems = new HashMap<>(); private final Map hashtagsItems = new HashMap<>(); + private List timelineDefinitions; + private int count; + private Fragment[] fragments; + private FrameLayout[] tabViews; + private TimelineDefinition[] timelines; + private final Map timelinesByMenuItem = new HashMap<>(); + private SubMenu hashtagsMenu, listsMenu; + private PopupMenu overflowPopup; + private View overflowActionView = null; + private boolean announcementsBadged, settingsBadged; @Override public void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); + E.register(this); accountID = getArguments().getString("account"); + timelineDefinitions = GlobalUserPreferences.pinnedTimelines.getOrDefault(accountID, TimelineDefinition.DEFAULT_TIMELINES); + assert timelineDefinitions != null; + if (timelineDefinitions.size() == 0) timelineDefinitions = List.of(TimelineDefinition.HOME_TIMELINE); + count = timelineDefinitions.size(); + fragments = new Fragment[count]; + tabViews = new FrameLayout[count]; + timelines = new TimelineDefinition[count]; } @Override @@ -102,31 +125,40 @@ public class HomeTabFragment extends MastodonToolbarFragment implements Scrollab pager = new ViewPager2(getContext()); toolbarFrame = (FrameLayout) LayoutInflater.from(getContext()).inflate(R.layout.home_toolbar, getToolbar(), false); - if (fragments.size() == 0) { + if (fragments[0] == null) { Bundle args = new Bundle(); args.putString("account", accountID); args.putBoolean("__is_tab", true); + args.putBoolean("onlyPosts", true); - fragments.add(new HomeTimelineFragment()); - fragments.add(new LocalTimelineFragment()); - if (GlobalUserPreferences.showFederatedTimeline) fragments.add(new FederatedTimelineFragment()); + for (int i = 0; i < timelineDefinitions.size(); i++) { + TimelineDefinition tl = timelineDefinitions.get(i); + fragments[i] = tl.getFragment(); + timelines[i] = tl; + } FragmentTransaction transaction = getChildFragmentManager().beginTransaction(); - for (int i = 0; i < fragments.size(); i++) { - fragments.get(i).setArguments(args); + for (int i = 0; i < count; i++) { + fragments[i].setArguments(timelines[i].populateArguments(new Bundle(args))); FrameLayout tabView = new FrameLayout(getActivity()); tabView.setLayoutParams(new ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT)); tabView.setVisibility(View.GONE); tabView.setId(i + 1); - transaction.add(i + 1, fragments.get(i)); + transaction.add(i + 1, fragments[i]); view.addView(tabView); - tabViews.add(tabView); + tabViews[i] = tabView; } transaction.commit(); } view.addView(pager, new ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT)); + overflowActionView = UiUtils.makeOverflowActionView(getContext()); + overflowPopup = new PopupMenu(getContext(), overflowActionView); + overflowPopup.setOnMenuItemClickListener(this::onOptionsItemSelected); + overflowActionView.setOnClickListener(l -> overflowPopup.show()); + overflowActionView.setOnTouchListener(overflowPopup.getDragToOpenListener()); + return view; } @@ -140,37 +172,36 @@ public class HomeTabFragment extends MastodonToolbarFragment implements Scrollab collapsedChevron = toolbarFrame.findViewById(R.id.collapsed_chevron); switcher = toolbarFrame.findViewById(R.id.switcher_btn); switcherPopup = new PopupMenu(getContext(), switcher); - switcherPopup.inflate(R.menu.home_switcher); switcherPopup.setOnMenuItemClickListener(this::onSwitcherItemSelected); UiUtils.enablePopupMenuIcons(getContext(), switcherPopup); - switcher.setOnClickListener(v->{ - updateSwitcherMenu(); - switcherPopup.show(); - }); - View.OnTouchListener listener = switcherPopup.getDragToOpenListener(); - switcher.setOnTouchListener((v, m)-> { - updateSwitcherMenu(); - return listener.onTouch(v, m); - }); + switcher.setOnClickListener(v->switcherPopup.show()); + switcher.setOnTouchListener(switcherPopup.getDragToOpenListener()); + updateSwitcherMenu(); UiUtils.reduceSwipeSensitivity(pager); pager.setUserInputEnabled(!GlobalUserPreferences.disableSwipe); pager.setAdapter(new HomePagerAdapter()); - pager.registerOnPageChangeCallback(new ViewPager2.OnPageChangeCallback(){ + pager.registerOnPageChangeCallback(new ViewPager2.OnPageChangeCallback() { @Override public void onPageSelected(int position){ + if (!reduceMotion) { + // setting this here because page transformer appears to fire too late so the + // animation can appear bumpy, especially when navigating to a further-away tab + switcher.setScaleY(0.85f); + switcher.setScaleX(0.85f); + switcher.setAlpha(0.65f); + } updateSwitcherIcon(position); - if (position==0) return; - hideNewPostsButton(); - if (fragments.get(position) instanceof BaseRecyclerFragment page){ + if (!timelines[position].equals(TimelineDefinition.HOME_TIMELINE)) hideNewPostsButton(); + if (fragments[position] instanceof BaseRecyclerFragment page){ if(!page.loaded && !page.isDataLoading()) page.loadData(); } } }); - if (!GlobalUserPreferences.reduceMotion) { + if (!reduceMotion) { pager.setPageTransformer((v, pos) -> { - if (tabViews.get(pager.getCurrentItem()) != v) return; + if (reduceMotion || tabViews[pager.getCurrentItem()] != v) return; float scaleFactor = Math.max(0.85f, 1 - Math.abs(pos) * 0.06f); switcher.setScaleY(scaleFactor); switcher.setScaleX(scaleFactor); @@ -180,15 +211,37 @@ public class HomeTabFragment extends MastodonToolbarFragment implements Scrollab updateToolbarLogo(); + ViewTreeObserver vto = getToolbar().getViewTreeObserver(); + if (vto.isAlive()) { + vto.addOnGlobalLayoutListener(() -> { + Toolbar t = getToolbar(); + if (t == null) return; + int toolbarWidth = t.getWidth(); + if (toolbarWidth == 0) return; + + int toolbarFrameWidth = toolbarFrame.getWidth(); + int padding = toolbarWidth - toolbarFrameWidth; + FrameLayout parent = ((FrameLayout) toolbarShowNewPostsBtn.getParent()); + if (padding == parent.getPaddingStart()) return; + + // toolbar frame goes from screen edge to beginning of right-aligned option buttons. + // centering button by applying the same space on the left + parent.setPaddingRelative(padding, 0, 0, 0); + toolbarShowNewPostsBtn.setMaxWidth(toolbarWidth - padding * 2); + + switcher.setPivotX(V.dp(28)); // padding + half of icon + switcher.setPivotY(switcher.getHeight() / 2f); + }); + } + if(GithubSelfUpdater.needSelfUpdating()){ - E.register(this); updateUpdateState(GithubSelfUpdater.getInstance().getState()); } new GetLists().setCallback(new Callback<>() { @Override public void onSuccess(List lists) { - addItemsToMap(lists, listItems); + updateList(lists, listItems); } @Override @@ -200,7 +253,7 @@ public class HomeTabFragment extends MastodonToolbarFragment implements Scrollab new GetFollowedHashtags().setCallback(new Callback<>() { @Override public void onSuccess(HeaderPaginationList hashtags) { - addItemsToMap(hashtags, hashtagsItems); + updateList(hashtags, hashtagsItems); } @Override @@ -208,6 +261,47 @@ public class HomeTabFragment extends MastodonToolbarFragment implements Scrollab error.showToast(getContext()); } }).exec(accountID); + + new GetAnnouncements(false).setCallback(new Callback<>() { + @Override + public void onSuccess(List result) { + if (getActivity() == null) return; + if (result.stream().anyMatch(a -> !a.read)) { + announcementsBadged = true; + announcements.setVisible(false); + announcementsAction.setVisible(true); + } + } + + @Override + public void onError(ErrorResponse error) { + error.showToast(getActivity()); + } + }).exec(accountID); + } + + private void addListsToOverflowMenu() { + Context ctx = getContext(); + listsMenu.clear(); + listsMenu.getItem().setVisible(listItems.size() > 0); + UiUtils.insetPopupMenuIcon(ctx, UiUtils.makeBackItem(listsMenu)); + listItems.forEach((id, list) -> { + MenuItem item = listsMenu.add(Menu.NONE, id, Menu.NONE, list.title); + item.setIcon(R.drawable.ic_fluent_people_24_regular); + UiUtils.insetPopupMenuIcon(ctx, item); + }); + } + + private void addHashtagsToOverflowMenu() { + Context ctx = getContext(); + hashtagsMenu.clear(); + hashtagsMenu.getItem().setVisible(hashtagsItems.size() > 0); + UiUtils.insetPopupMenuIcon(ctx, UiUtils.makeBackItem(hashtagsMenu)); + hashtagsItems.forEach((id, hashtag) -> { + MenuItem item = hashtagsMenu.add(Menu.NONE, id, Menu.NONE, hashtag.name); + item.setIcon(R.drawable.ic_fluent_number_symbol_24_regular); + UiUtils.insetPopupMenuIcon(ctx, item); + }); } public void updateToolbarLogo(){ @@ -222,11 +316,6 @@ public class HomeTabFragment extends MastodonToolbarFragment implements Scrollab updateSwitcherIcon(pager.getCurrentItem()); -// toolbarLogo=new ImageView(getActivity()); -// toolbarLogo.setScaleType(ImageView.ScaleType.CENTER); -// toolbarLogo.setImageResource(R.drawable.logo); -// toolbarLogo.setImageTintList(ColorStateList.valueOf(UiUtils.getThemeColor(getActivity(), android.R.attr.textColorPrimary))); - toolbarShowNewPostsBtn=toolbarFrame.findViewById(R.id.show_new_posts_btn); toolbarShowNewPostsBtn.setCompoundDrawableTintList(toolbarShowNewPostsBtn.getTextColors()); if(Build.VERSION.SDK_INT= Build.VERSION_CODES.P) { + m.setGroupDividerEnabled(true); } } @Override public void onCreateOptionsMenu(Menu menu, MenuInflater inflater){ inflater.inflate(R.menu.home, menu); - announcements = menu.findItem(R.id.announcements); - new GetAnnouncements(false).setCallback(new Callback<>() { - @Override - public void onSuccess(List result) { - boolean hasUnread = result.stream().anyMatch(a -> !a.read); - announcements.setIcon(hasUnread ? R.drawable.ic_announcements_24_badged : R.drawable.ic_fluent_megaphone_24_regular); - } + menu.findItem(R.id.overflow).setActionView(overflowActionView); + announcementsAction = menu.findItem(R.id.announcements_action); + settingsAction = menu.findItem(R.id.settings_action); - @Override - public void onError(ErrorResponse error) { - error.showToast(getActivity()); - } - }).exec(accountID); + updateOverflowMenu(); } - private void addItemsToMap(List addItems, Map items) { - if (addItems.size() == 0) return; + private void updateList(List addItems, Map items) { + if (addItems.size() == 0 || getActivity() == null) return; for (int i = 0; i < addItems.size(); i++) items.put(View.generateViewId(), addItems.get(i)); - updateSwitcherMenu(); + updateOverflowMenu(); } private void updateSwitcherMenu() { - Context context = getContext(); - switcherPopup.getMenu().findItem(R.id.federated).setVisible(GlobalUserPreferences.showFederatedTimeline); + Menu switcherMenu = switcherPopup.getMenu(); + switcherMenu.clear(); + timelinesByMenuItem.clear(); - if (!listItems.isEmpty()) { - MenuItem listsItem = switcherPopup.getMenu().findItem(R.id.lists); - listsItem.setVisible(true); - SubMenu listsMenu = listsItem.getSubMenu(); - listsMenu.clear(); - listItems.forEach((id, list) -> { - MenuItem item = listsMenu.add(Menu.NONE, id, Menu.NONE, list.title); - item.setIcon(R.drawable.ic_fluent_people_list_24_regular); - UiUtils.insetPopupMenuIcon(context, item); - }); + for (TimelineDefinition tl : timelines) { + int menuItemId = View.generateViewId(); + timelinesByMenuItem.put(menuItemId, tl); + MenuItem item = switcherMenu.add(0, menuItemId, 0, tl.getTitle(getContext())); + item.setIcon(tl.getIcon().iconRes); } - if (!hashtagsItems.isEmpty()) { - MenuItem hashtagsItem = switcherPopup.getMenu().findItem(R.id.followed_hashtags); - hashtagsItem.setVisible(true); - SubMenu hashtagsMenu = hashtagsItem.getSubMenu(); - hashtagsMenu.clear(); - hashtagsItems.forEach((id, hashtag) -> { - MenuItem item = hashtagsMenu.add(Menu.NONE, id, Menu.NONE, hashtag.name); - item.setIcon(R.drawable.ic_fluent_number_symbol_24_regular); - UiUtils.insetPopupMenuIcon(context, item); - }); - } + UiUtils.enablePopupMenuIcons(getContext(), switcherPopup); } private boolean onSwitcherItemSelected(MenuItem item) { int id = item.getItemId(); - ListTimeline list; - Hashtag hashtag; - if (id == R.id.home) { - navigateTo(0); + + Bundle args = new Bundle(); + args.putString("account", accountID); + + if (id == R.id.menu_back) { + switcher.post(() -> switcherPopup.show()); return true; - } else if (id == R.id.local) { - navigateTo(1); - return true; - } else if (id == R.id.federated) { - navigateTo(2); - return true; - } else if ((list = listItems.get(id)) != null) { - Bundle args = new Bundle(); - args.putString("account", accountID); - args.putString("listID", list.id); - args.putString("listTitle", list.title); - args.putInt("repliesPolicy", list.repliesPolicy.ordinal()); - Nav.go(getActivity(), ListTimelineFragment.class, args); - } else if ((hashtag = hashtagsItems.get(id)) != null) { - UiUtils.openHashtagTimeline(getActivity(), accountID, hashtag.name, hashtag.following); } + + TimelineDefinition tl = timelinesByMenuItem.get(id); + if (tl != null) { + for (int i = 0; i < timelines.length; i++) { + if (timelines[i] == tl) { + navigateTo(i); + return true; + } + } + } + return false; } - private void navigateTo(int i) { - navigateTo(i, !GlobalUserPreferences.reduceMotion); + navigateTo(i, !reduceMotion); } private void navigateTo(int i, boolean smooth) { @@ -365,32 +428,43 @@ public class HomeTabFragment extends MastodonToolbarFragment implements Scrollab } private void updateSwitcherIcon(int i) { - timelineIcon.setImageResource(switch (i) { - default -> R.drawable.ic_fluent_home_24_regular; - case 1 -> R.drawable.ic_fluent_people_community_24_regular; - case 2 -> R.drawable.ic_fluent_earth_24_regular; - }); - timelineTitle.setText(switch (i) { - default -> R.string.sk_timeline_home; - case 1 -> R.string.sk_timeline_local; - case 2 -> R.string.sk_timeline_federated; - }); + timelineIcon.setImageResource(timelines[i].getIcon().iconRes); + timelineTitle.setText(timelines[i].getTitle(getContext())); } @Override public boolean onOptionsItemSelected(MenuItem item){ Bundle args=new Bundle(); args.putString("account", accountID); - if (item.getItemId() == R.id.settings) Nav.go(getActivity(), SettingsFragment.class, args); - if (item.getItemId() == R.id.announcements) { + int id = item.getItemId(); + ListTimeline list; + Hashtag hashtag; + + if (item.getItemId() == R.id.menu_back) { + getToolbar().post(() -> overflowPopup.show()); + return true; + } else if (id == R.id.settings || id == R.id.settings_action) { + Nav.go(getActivity(), SettingsFragment.class, args); + } else if (id == R.id.announcements || id == R.id.announcements_action) { Nav.goForResult(getActivity(), AnnouncementsFragment.class, args, ANNOUNCEMENTS_RESULT, this); + } else if (id == R.id.edit_timelines) { + Nav.go(getActivity(), EditTimelinesFragment.class, args); + } else if ((list = listItems.get(id)) != null) { + args.putString("listID", list.id); + args.putString("listTitle", list.title); + if (list.repliesPolicy != null) args.putInt("repliesPolicy", list.repliesPolicy.ordinal()); + Nav.go(getActivity(), ListTimelineFragment.class, args); + } else if ((hashtag = hashtagsItems.get(id)) != null) { + args.putString("hashtag", hashtag.name); + args.putBoolean("following", hashtag.following); + Nav.go(getActivity(), HashtagTimelineFragment.class, args); } return true; } @Override public void scrollToTop(){ - ((ScrollableToTop) fragments.get(pager.getCurrentItem())).scrollToTop(); + ((ScrollableToTop) fragments[pager.getCurrentItem()]).scrollToTop(); } public void hideNewPostsButton(){ @@ -411,7 +485,7 @@ public class HomeTabFragment extends MastodonToolbarFragment implements Scrollab ObjectAnimator.ofFloat(toolbarShowNewPostsBtn, View.SCALE_Y, .8f), ObjectAnimator.ofFloat(collapsedChevron, View.ALPHA, 0f) ); - set.setDuration(GlobalUserPreferences.reduceMotion ? 0 : 300); + set.setDuration(reduceMotion ? 0 : 300); set.setInterpolator(CubicBezierInterpolator.DEFAULT); set.addListener(new AnimatorListenerAdapter(){ @Override @@ -426,7 +500,7 @@ public class HomeTabFragment extends MastodonToolbarFragment implements Scrollab } public void showNewPostsButton(){ - if(newPostsBtnShown || pager == null || pager.getCurrentItem() != 0) + if(newPostsBtnShown || pager == null || !timelines[pager.getCurrentItem()].equals(TimelineDefinition.HOME_TIMELINE)) return; newPostsBtnShown=true; if(currentNewPostsAnim!=null){ @@ -444,7 +518,7 @@ public class HomeTabFragment extends MastodonToolbarFragment implements Scrollab ObjectAnimator.ofFloat(toolbarShowNewPostsBtn, View.SCALE_Y, 1f), ObjectAnimator.ofFloat(collapsedChevron, View.ALPHA, 1f) ); - set.setDuration(GlobalUserPreferences.reduceMotion ? 0 : 300); + set.setDuration(reduceMotion ? 0 : 300); set.setInterpolator(CubicBezierInterpolator.DEFAULT); set.addListener(new AnimatorListenerAdapter(){ @Override @@ -469,15 +543,20 @@ public class HomeTabFragment extends MastodonToolbarFragment implements Scrollab } @Override - public void onFragmentResult(int reqCode, boolean noMoreUnread, Bundle result){ - if (reqCode == ANNOUNCEMENTS_RESULT && noMoreUnread) { - announcements.setIcon(R.drawable.ic_fluent_megaphone_24_regular); + public void onFragmentResult(int reqCode, boolean success, Bundle result){ + if (reqCode == ANNOUNCEMENTS_RESULT && success) { + announcementsBadged = false; + announcements.setVisible(true); + announcementsAction.setVisible(false); } } private void updateUpdateState(GithubSelfUpdater.UpdateState state){ - if(state!=GithubSelfUpdater.UpdateState.NO_UPDATE && state!=GithubSelfUpdater.UpdateState.CHECKING) - getToolbar().getMenu().findItem(R.id.settings).setIcon(R.drawable.ic_settings_24_badged); + if(state!=GithubSelfUpdater.UpdateState.NO_UPDATE && state!=GithubSelfUpdater.UpdateState.CHECKING) { + settingsBadged = true; + settingsAction.setVisible(true); + settings.setVisible(false); + } } @Subscribe @@ -497,11 +576,26 @@ public class HomeTabFragment extends MastodonToolbarFragment implements Scrollab @Override public void onDestroyView(){ super.onDestroyView(); + if (overflowPopup != null) { + overflowPopup.dismiss(); + overflowPopup = null; + } + if (switcherPopup != null) { + switcherPopup.dismiss(); + switcherPopup = null; + } if(GithubSelfUpdater.needSelfUpdating()){ E.unregister(this); } } + @Override + protected void onShown() { + super.onShown(); + Object pinnedTimelines = GlobalUserPreferences.pinnedTimelines.get(accountID); + if (pinnedTimelines != null && timelineDefinitions != pinnedTimelines) UiUtils.restartApp(); + } + @Override public void onViewStateRestored(Bundle savedInstanceState) { super.onViewStateRestored(savedInstanceState); @@ -515,12 +609,61 @@ public class HomeTabFragment extends MastodonToolbarFragment implements Scrollab outState.putInt("selectedTab", pager.getCurrentItem()); } + @Subscribe + public void onHashtagUpdatedEvent(HashtagUpdatedEvent event) { + handleListEvent(hashtagsItems, h -> h.name.equalsIgnoreCase(event.name), event.following, () -> { + Hashtag hashtag = new Hashtag(); + hashtag.name = event.name; + hashtag.following = true; + return hashtag; + }); + } + + @Subscribe + public void onListDeletedEvent(ListDeletedEvent event) { + handleListEvent(listItems, l -> l.id.equals(event.id), false, null); + } + + @Subscribe + public void onListUpdatedCreatedEvent(ListUpdatedCreatedEvent event) { + handleListEvent(listItems, l -> l.id.equals(event.id), true, () -> { + ListTimeline list = new ListTimeline(); + list.id = event.id; + list.title = event.title; + list.repliesPolicy = event.repliesPolicy; + return list; + }); + } + + private void handleListEvent( + Map existingThings, + Predicate matchExisting, + boolean shouldBeInList, + Supplier makeNewThing + ) { + Optional> existingThing = existingThings.entrySet().stream() + .filter(e -> matchExisting.test(e.getValue())).findFirst(); + if (shouldBeInList) { + existingThings.put(existingThing.isPresent() + ? existingThing.get().getKey() : View.generateViewId(), makeNewThing.get()); + updateOverflowMenu(); + } else if (existingThing.isPresent() && !shouldBeInList) { + existingThings.remove(existingThing.get().getKey()); + updateOverflowMenu(); + } + } + + public Collection getHashtags() { + return hashtagsItems.values(); + } + private class HomePagerAdapter extends RecyclerView.Adapter { @NonNull @Override public SimpleViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { - FrameLayout tabView = tabViews.get(viewType % getItemCount()); - ((ViewGroup)tabView.getParent()).removeView(tabView); + FrameLayout tabView = tabViews[viewType % getItemCount()]; + ViewGroup tabParent = (ViewGroup) tabView.getParent(); + if (tabParent != null) tabParent.removeView(tabView); tabView.setVisibility(View.VISIBLE); return new SimpleViewHolder(tabView); } @@ -530,7 +673,7 @@ public class HomeTabFragment extends MastodonToolbarFragment implements Scrollab @Override public int getItemCount(){ - return fragments.size(); + return count; } @Override diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/HomeTimelineFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/HomeTimelineFragment.java index 0b2e43884..60203b167 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/HomeTimelineFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/HomeTimelineFragment.java @@ -8,6 +8,9 @@ import androidx.annotation.NonNull; import androidx.recyclerview.widget.RecyclerView; import org.joinmastodon.android.GlobalUserPreferences; +import org.joinmastodon.android.E; +import org.joinmastodon.android.R; +import org.joinmastodon.android.api.requests.markers.SaveMarkers; import org.joinmastodon.android.api.requests.timelines.GetHomeTimeline; import org.joinmastodon.android.api.session.AccountSessionManager; import org.joinmastodon.android.events.StatusCreatedEvent; @@ -29,9 +32,15 @@ import me.grishka.appkit.api.ErrorResponse; import me.grishka.appkit.api.SimpleCallback; import me.grishka.appkit.utils.V; -public class HomeTimelineFragment extends FabStatusListFragment { +public class HomeTimelineFragment extends StatusListFragment { private HomeTabFragment parent; private String maxID; + private String lastSavedMarkerID; + + @Override + protected boolean withComposeButton() { + return true; + } @Override public void onAttach(Activity activity){ @@ -54,8 +63,7 @@ public class HomeTimelineFragment extends FabStatusListFragment { .getHomeTimeline(offset>0 ? maxID : null, count, refreshing, new SimpleCallback<>(this){ @Override public void onSuccess(CacheablePaginatedResponse> result){ - if(getActivity()==null) - return; + if (getActivity() == null) return; List filteredItems = filterPosts(result.items); onDataLoaded(filteredItems, !result.items.isEmpty()); maxID=result.maxID; @@ -91,6 +99,29 @@ public class HomeTimelineFragment extends FabStatusListFragment { } } + @Override + protected void onHidden(){ + super.onHidden(); + if(!data.isEmpty()){ + String topPostID=displayItems.get(Math.max(0, list.getChildAdapterPosition(list.getChildAt(0))-getMainAdapterOffset())).parentID; + if(!topPostID.equals(lastSavedMarkerID)){ + lastSavedMarkerID=topPostID; + new SaveMarkers(topPostID, null) + .setCallback(new Callback<>(){ + @Override + public void onSuccess(SaveMarkers.Response result){ + } + + @Override + public void onError(ErrorResponse error){ + lastSavedMarkerID=null; + } + }) + .exec(accountID); + } + } + } + public void onStatusCreated(StatusCreatedEvent ev){ prependItems(Collections.singletonList(ev.status), true); } @@ -123,7 +154,7 @@ public class HomeTimelineFragment extends FabStatusListFragment { toAdd=toAdd.stream().filter(filterPredicate).collect(Collectors.toList()); if(!toAdd.isEmpty()){ prependItems(toAdd, true); - if (parent != null) parent.showNewPostsButton(); + if (parent != null && GlobalUserPreferences.showNewPostsButton) parent.showNewPostsButton(); AccountSessionManager.getInstance().getAccount(accountID).getCacheController().putHomeTimeline(toAdd, false); } } diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/IsOnTop.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/IsOnTop.java index 3b1c999e9..071e44775 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/IsOnTop.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/IsOnTop.java @@ -1,11 +1,13 @@ package org.joinmastodon.android.fragments; +import androidx.annotation.Nullable; import androidx.recyclerview.widget.RecyclerView; public interface IsOnTop { boolean isOnTop(); - default boolean isRecyclerViewOnTop(RecyclerView list) { + default boolean isRecyclerViewOnTop(@Nullable RecyclerView list) { + if (list == null) return true; return !list.canScrollVertically(-1); } } 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 236636370..79422cbb7 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/ListTimelineFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/ListTimelineFragment.java @@ -9,17 +9,26 @@ import android.view.View; import android.view.ViewGroup; import android.widget.ImageButton; +import androidx.annotation.Nullable; + +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.GetList; import org.joinmastodon.android.api.requests.lists.UpdateList; import org.joinmastodon.android.api.requests.timelines.GetListTimeline; +import org.joinmastodon.android.events.ListDeletedEvent; +import org.joinmastodon.android.events.ListUpdatedCreatedEvent; +import org.joinmastodon.android.model.Filter; import org.joinmastodon.android.model.ListTimeline; import org.joinmastodon.android.model.Status; +import org.joinmastodon.android.model.TimelineDefinition; import org.joinmastodon.android.ui.M3AlertDialogBuilder; import org.joinmastodon.android.ui.utils.UiUtils; import org.joinmastodon.android.ui.views.ListTimelineEditor; +import org.joinmastodon.android.utils.StatusFilterPredicate; import java.util.List; +import java.util.stream.Collectors; import me.grishka.appkit.Nav; import me.grishka.appkit.api.Callback; @@ -28,14 +37,15 @@ import me.grishka.appkit.api.SimpleCallback; import me.grishka.appkit.utils.V; -public class ListTimelineFragment extends StatusListFragment { +public class ListTimelineFragment extends PinnableStatusListFragment { private String listID; private String listTitle; + @Nullable private ListTimeline.RepliesPolicy repliesPolicy; - private ImageButton fab; - public ListTimelineFragment() { - setListLayoutId(R.layout.recycler_fragment_with_fab); + @Override + protected boolean withComposeButton() { + return true; } @Override @@ -48,39 +58,58 @@ public class ListTimelineFragment extends StatusListFragment { setTitle(listTitle); setHasOptionsMenu(true); + + new GetList(listID).setCallback(new Callback<>() { + @Override + public void onSuccess(ListTimeline listTimeline) { + 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; + } + } + + @Override + public void onError(ErrorResponse error) { + error.showToast(getContext()); + } + }); } @Override public void onCreateOptionsMenu(Menu menu, MenuInflater inflater) { - super.onCreateOptionsMenu(menu, inflater); inflater.inflate(R.menu.list, menu); + super.onCreateOptionsMenu(menu, inflater); + UiUtils.enableOptionsMenuIcons(getContext(), menu, R.id.pin); } @Override public boolean onOptionsItemSelected(MenuItem item) { - Bundle args = new Bundle(); - args.putString("listID", listID); + if (super.onOptionsItemSelected(item)) return true; if (item.getItemId() == R.id.edit) { ListTimelineEditor editor = new ListTimelineEditor(getContext()); editor.applyList(listTitle, repliesPolicy); new M3AlertDialogBuilder(getActivity()) .setTitle(R.string.sk_edit_list_title) - .setIcon(R.drawable.ic_fluent_people_list_28_regular) + .setIcon(R.drawable.ic_fluent_people_28_regular) .setView(editor) .setPositiveButton(R.string.save, (d, which) -> { - new UpdateList(listID, editor.getTitle(), editor.getRepliesPolicy()).setCallback(new Callback<>() { + String newTitle = editor.getTitle().trim(); + setTitle(newTitle); + new UpdateList(listID, newTitle, editor.getRepliesPolicy()).setCallback(new Callback<>() { @Override public void onSuccess(ListTimeline list) { + if (getActivity() == null) return; setTitle(list.title); listTitle = list.title; repliesPolicy = list.repliesPolicy; - args.putString("listTitle", listTitle); - args.putInt("repliesPolicy", repliesPolicy.ordinal()); - setResult(true, args); + E.post(new ListUpdatedCreatedEvent(listID, listTitle, repliesPolicy)); } @Override public void onError(ErrorResponse error) { + setTitle(listTitle); error.showToast(getContext()); } }).exec(accountID); @@ -89,24 +118,30 @@ public class ListTimelineFragment extends StatusListFragment { .show(); } else if (item.getItemId() == R.id.delete) { UiUtils.confirmDeleteList(getActivity(), accountID, listID, listTitle, () -> { - args.putBoolean("deleted", true); - setResult(true, args); + E.post(new ListDeletedEvent(listID)); Nav.finish(this); }); } return true; } + @Override + protected TimelineDefinition makeTimelineDefinition() { + return TimelineDefinition.ofList(listID, listTitle); + } + @Override protected void doLoadData(int offset, int count) { currentRequest=new GetListTimeline(listID, offset==0 ? null : getMaxID(), null, count, null) .setCallback(new SimpleCallback<>(this) { @Override public void onSuccess(List result) { + if (getActivity() == null) return; + result=result.stream().filter(new StatusFilterPredicate(accountID, Filter.FilterContext.HOME)).collect(Collectors.toList()); onDataLoaded(result, !result.isEmpty()); } }) - .exec(accountID); + .exec(accountID); } @Override @@ -117,14 +152,7 @@ public class ListTimelineFragment extends StatusListFragment { } @Override - public void onViewCreated(View view, Bundle savedInstanceState) { - super.onViewCreated(view, savedInstanceState); - fab=view.findViewById(R.id.fab); - fab.setOnClickListener(this::onFabClick); - fab.setOnLongClickListener(v -> UiUtils.pickAccountForCompose(getActivity(), accountID)); - } - - private void onFabClick(View v){ + protected void onFabClick(View v){ Bundle args=new Bundle(); args.putString("account", accountID); Nav.go(getActivity(), ComposeFragment.class, args); diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/ListTimelinesFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/ListTimelinesFragment.java index 2a22ae0d1..2b7c6a874 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/ListTimelinesFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/ListTimelinesFragment.java @@ -12,12 +12,17 @@ import android.widget.TextView; import androidx.annotation.NonNull; import androidx.recyclerview.widget.RecyclerView; +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.lists.AddAccountsToList; import org.joinmastodon.android.api.requests.lists.CreateList; 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.ui.DividerItemDecoration; import org.joinmastodon.android.ui.M3AlertDialogBuilder; @@ -37,210 +42,218 @@ import me.grishka.appkit.utils.BindableViewHolder; import me.grishka.appkit.views.UsableRecyclerView; public class ListTimelinesFragment extends BaseRecyclerFragment implements ScrollableToTop { - private static final int LIST_CHANGED_RESULT = 987; + private String accountId; + private String profileAccountId; + private final HashMap userInListBefore = new HashMap<>(); + private final HashMap userInList = new HashMap<>(); + private ListsAdapter adapter; - private String accountId; - private String profileAccountId; - private String profileDisplayUsername; - private HashMap userInListBefore = new HashMap<>(); - private HashMap userInList = new HashMap<>(); - private int inProgress = 0; - private ListsAdapter adapter; + public ListTimelinesFragment() { + super(10); + } - public ListTimelinesFragment() { - super(10); - } + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + Bundle args=getArguments(); + accountId=args.getString("account"); + setHasOptionsMenu(true); + E.register(this); - @Override - public void onCreate(Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - Bundle args=getArguments(); - accountId=args.getString("account"); - setHasOptionsMenu(true); + if(args.containsKey("profileAccount")){ + profileAccountId=args.getString("profileAccount"); + String profileDisplayUsername = args.getString("profileDisplayUsername"); + setTitle(getString(R.string.sk_lists_with_user, profileDisplayUsername)); + } else { + setTitle(R.string.sk_your_lists); + } + } - if(args.containsKey("profileAccount")){ - profileAccountId=args.getString("profileAccount"); - profileDisplayUsername=args.getString("profileDisplayUsername"); - setTitle(getString(R.string.sk_lists_with_user, profileDisplayUsername)); - } else { - setTitle(R.string.sk_your_lists); - } - } + @Override + protected void onShown(){ + super.onShown(); + if(!getArguments().getBoolean("noAutoLoad") && !loaded && !dataLoading) + loadData(); + } - @Override - protected void onShown(){ - super.onShown(); - if(!getArguments().getBoolean("noAutoLoad") && !loaded && !dataLoading) - loadData(); - } + @Override + public void onViewCreated(View view, Bundle savedInstanceState) { + super.onViewCreated(view, savedInstanceState); + list.addItemDecoration(new DividerItemDecoration(getActivity(), R.attr.colorPollVoted, 0.5f, 56, 16)); + } - @Override - public void onViewCreated(View view, Bundle savedInstanceState) { - super.onViewCreated(view, savedInstanceState); - list.addItemDecoration(new DividerItemDecoration(getActivity(), R.attr.colorPollVoted, 0.5f, 56, 16)); - } + @Override + public void onCreateOptionsMenu(Menu menu, MenuInflater inflater) { + inflater.inflate(R.menu.menu_list, menu); + } - @Override - public void onCreateOptionsMenu(Menu menu, MenuInflater inflater) { - inflater.inflate(R.menu.menu_list, menu); - } + @Override + public boolean onOptionsItemSelected(MenuItem item) { + if (item.getItemId() == R.id.create) { + ListTimelineEditor editor = new ListTimelineEditor(getContext()); + new M3AlertDialogBuilder(getActivity()) + .setTitle(R.string.sk_create_list_title) + .setIcon(R.drawable.ic_fluent_people_add_28_regular) + .setView(editor) + .setPositiveButton(R.string.sk_create, (d, which) -> + new CreateList(editor.getTitle(), editor.getRepliesPolicy()).setCallback(new Callback<>() { + @Override + public void onSuccess(ListTimeline list) { + data.add(0, list); + adapter.notifyItemRangeInserted(0, 1); + E.post(new ListUpdatedCreatedEvent(list.id, list.title, list.repliesPolicy)); + } - @Override - public boolean onOptionsItemSelected(MenuItem item) { - if (item.getItemId() == R.id.create) { - ListTimelineEditor editor = new ListTimelineEditor(getContext()); - new M3AlertDialogBuilder(getActivity()) - .setTitle(R.string.sk_create_list_title) - .setIcon(R.drawable.ic_fluent_people_add_28_regular) - .setView(editor) - .setPositiveButton(R.string.sk_create, (d, which) -> { - new CreateList(editor.getTitle(), editor.getRepliesPolicy()).setCallback(new Callback<>() { - @Override - public void onSuccess(ListTimeline list) { - saveListMembership(list.id, true); - data.add(0, list); - adapter.notifyItemRangeInserted(0, 1); - } + @Override + public void onError(ErrorResponse error) { + error.showToast(getContext()); + } + }).exec(accountId) + ) + .setNegativeButton(R.string.cancel, (d, which) -> {}) + .show(); + } + return true; + } - @Override - public void onError(ErrorResponse error) { - error.showToast(getContext()); - } - }).exec(accountId); - }) - .setNegativeButton(R.string.cancel, (d, which) -> {}) - .show(); - } - return true; - } + 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) {} - 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 SimpleCallback<>(this) { - @Override - public void onSuccess(Object o) {} - }).exec(accountId); - } + @Override + public void onError(ErrorResponse error) { + error.showToast(getContext()); + } + }).exec(accountId); + } - @Override - protected void doLoadData(int offset, int count){ - userInListBefore.clear(); - userInList.clear(); - currentRequest=(profileAccountId != null ? new GetLists(profileAccountId) : new GetLists()) - .setCallback(new SimpleCallback<>(this) { - @Override - public void onSuccess(List lists) { - for (ListTimeline l : lists) userInListBefore.put(l.id, true); - userInList.putAll(userInListBefore); - if (profileAccountId == null || !lists.isEmpty()) onDataLoaded(lists, false); - if (profileAccountId == null) return; + @Override + protected void doLoadData(int offset, int count){ + userInListBefore.clear(); + userInList.clear(); + currentRequest=(profileAccountId != null ? new GetLists(profileAccountId) : new GetLists()) + .setCallback(new SimpleCallback<>(this) { + @Override + public void onSuccess(List lists) { + if (getActivity() == null) return; + for (ListTimeline 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<>(ListTimelinesFragment.this) { - @Override - public void onSuccess(List allLists) { - List newLists = new ArrayList<>(); - for (ListTimeline 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); - } - } - userInList.putAll(userInListBefore); - onDataLoaded(newLists, false); - } - }).exec(accountId); - } - }) - .exec(accountId); - } + currentRequest=new GetLists().setCallback(new SimpleCallback<>(ListTimelinesFragment.this) { + @Override + public void onSuccess(List allLists) { + if (getActivity() == null) return; + List newLists = new ArrayList<>(); + for (ListTimeline 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); + } + } + userInList.putAll(userInListBefore); + onDataLoaded(newLists, false); + } + }).exec(accountId); + } + }) + .exec(accountId); + } - @Override - public void onFragmentResult(int reqCode, boolean listChanged, Bundle result){ - if (reqCode == LIST_CHANGED_RESULT && listChanged) { - String listID = result.getString("listID"); - for (int i = 0; i < data.size(); i++) { - ListTimeline item = data.get(i); - if (item.id.equals(listID)) { - if (result.getBoolean("deleted")) { - data.remove(i); - adapter.notifyItemRemoved(i); - } else { - item.title = result.getString("listTitle", item.title); - item.repliesPolicy = ListTimeline.RepliesPolicy.values()[result.getInt("repliesPolicy")]; - adapter.notifyItemChanged(i); - } - break; - } - } - } - } + @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)) { + data.remove(i); + adapter.notifyItemRemoved(i); + break; + } + } + } - @Override - protected RecyclerView.Adapter getAdapter() { - return adapter = new ListsAdapter(); - } + @Subscribe + public void onListUpdatedCreatedEvent(ListUpdatedCreatedEvent event) { + for (int i = 0; i < data.size(); i++) { + ListTimeline item = data.get(i); + if (item.id.equals(event.id)) { + item.title = event.title; + item.repliesPolicy = event.repliesPolicy; + adapter.notifyItemChanged(i); + break; + } + } + } - @Override - public void scrollToTop() { - smoothScrollRecyclerViewToTop(list); - } + @Override + protected RecyclerView.Adapter getAdapter() { + return adapter = new ListsAdapter(); + } - private class ListsAdapter extends RecyclerView.Adapter{ - @NonNull - @Override - public ListViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType){ - return new ListViewHolder(); - } + @Override + public void scrollToTop() { + smoothScrollRecyclerViewToTop(list); + } - @Override - public void onBindViewHolder(@NonNull ListViewHolder holder, int position) { - holder.bind(data.get(position)); - } + private class ListsAdapter extends RecyclerView.Adapter{ + @NonNull + @Override + public ListViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType){ + return new ListViewHolder(); + } - @Override - public int getItemCount() { - return data.size(); - } - } + @Override + public void onBindViewHolder(@NonNull ListViewHolder holder, int position) { + holder.bind(data.get(position)); + } - private class ListViewHolder extends BindableViewHolder implements UsableRecyclerView.Clickable{ - private final TextView title; - private final CheckBox listToggle; + @Override + public int getItemCount() { + return data.size(); + } + } - public ListViewHolder(){ - super(getActivity(), R.layout.item_text, list); - title=findViewById(R.id.title); - listToggle=findViewById(R.id.list_toggle); - } + private class ListViewHolder extends BindableViewHolder implements UsableRecyclerView.Clickable{ + private final TextView title; + private final CheckBox listToggle; - @Override - public void onBind(ListTimeline item) { - title.setText(item.title); - title.setCompoundDrawablesRelativeWithIntrinsicBounds(itemView.getContext().getDrawable(R.drawable.ic_fluent_people_list_24_regular), null, null, null); - if (profileAccountId != null) { - Boolean checked = userInList.get(item.id); - listToggle.setVisibility(View.VISIBLE); - listToggle.setChecked(userInList.containsKey(item.id) && checked != null && checked); - listToggle.setOnClickListener(this::onClickToggle); - } else { - listToggle.setVisibility(View.GONE); - } - } + public ListViewHolder(){ + super(getActivity(), R.layout.item_text, list); + title=findViewById(R.id.title); + listToggle=findViewById(R.id.list_toggle); + } - private void onClickToggle(View view) { - saveListMembership(item.id, listToggle.isChecked()); - } + @Override + public void onBind(ListTimeline item) { + title.setText(item.title); + title.setCompoundDrawablesRelativeWithIntrinsicBounds(itemView.getContext().getDrawable(R.drawable.ic_fluent_people_24_regular), null, null, null); + if (profileAccountId != null) { + Boolean checked = userInList.get(item.id); + listToggle.setVisibility(View.VISIBLE); + listToggle.setChecked(userInList.containsKey(item.id) && checked != null && checked); + listToggle.setOnClickListener(this::onClickToggle); + } else { + listToggle.setVisibility(View.GONE); + } + } - @Override - public void onClick() { - Bundle args=new Bundle(); - args.putString("account", accountId); - args.putString("listID", item.id); - args.putString("listTitle", item.title); - args.putInt("repliesPolicy", item.repliesPolicy.ordinal()); - Nav.goForResult(getActivity(), ListTimelineFragment.class, args, LIST_CHANGED_RESULT, ListTimelinesFragment.this); - } - } + private void onClickToggle(View view) { + saveListMembership(item.id, listToggle.isChecked()); + } + + @Override + public void onClick() { + Bundle args=new Bundle(); + args.putString("account", accountId); + args.putString("listID", item.id); + args.putString("listTitle", item.title); + if (item.repliesPolicy != null) args.putInt("repliesPolicy", item.repliesPolicy.ordinal()); + Nav.go(getActivity(), ListTimelineFragment.class, args); + } + } } diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/NotificationsFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/NotificationsFragment.java index a8f4a537d..38b4d9ae6 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/NotificationsFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/NotificationsFragment.java @@ -44,7 +44,7 @@ public class NotificationsFragment extends MastodonToolbarFragment implements Sc private FrameLayout[] tabViews; private TabLayoutMediator tabLayoutMediator; - private NotificationsListFragment allNotificationsFragment, mentionsFragment, postsFragment; + private NotificationsListFragment allNotificationsFragment, mentionsFragment; private String accountID; @@ -104,13 +104,12 @@ public class NotificationsFragment extends MastodonToolbarFragment implements Sc pager=view.findViewById(R.id.pager); UiUtils.reduceSwipeSensitivity(pager); - tabViews=new FrameLayout[3]; + tabViews=new FrameLayout[2]; for(int i=0;i R.id.notifications_all; case 1 -> R.id.notifications_mentions; - case 2 -> R.id.notifications_posts; default -> throw new IllegalStateException("Unexpected value: "+i); }); tabView.setVisibility(View.GONE); @@ -120,6 +119,18 @@ public class NotificationsFragment extends MastodonToolbarFragment implements Sc tabLayout.setTabTextSize(V.dp(16)); tabLayout.setTabTextColors(UiUtils.getThemeColor(getActivity(), R.attr.colorTabInactive), UiUtils.getThemeColor(getActivity(), android.R.attr.textColorPrimary)); + tabLayout.addOnTabSelectedListener(new TabLayout.OnTabSelectedListener() { + @Override + public void onTabSelected(TabLayout.Tab tab) {} + + @Override + public void onTabUnselected(TabLayout.Tab tab) {} + + @Override + public void onTabReselected(TabLayout.Tab tab) { + scrollToTop(); + } + }); pager.setOffscreenPageLimit(4); pager.setUserInputEnabled(!GlobalUserPreferences.disableSwipe); @@ -150,15 +161,9 @@ public class NotificationsFragment extends MastodonToolbarFragment implements Sc mentionsFragment=new NotificationsListFragment(); mentionsFragment.setArguments(args); - args=new Bundle(args); - args.putBoolean("onlyPosts", true); - postsFragment=new NotificationsListFragment(); - postsFragment.setArguments(args); - getChildFragmentManager().beginTransaction() .add(R.id.notifications_all, allNotificationsFragment) .add(R.id.notifications_mentions, mentionsFragment) - .add(R.id.notifications_posts, postsFragment) .commit(); } @@ -168,7 +173,6 @@ public class NotificationsFragment extends MastodonToolbarFragment implements Sc tab.setText(switch(position){ case 0 -> R.string.all_notifications; case 1 -> R.string.mentions; - case 2 -> R.string.posts; default -> throw new IllegalStateException("Unexpected value: "+position); }); tab.view.textView.setAllCaps(true); @@ -183,6 +187,7 @@ public class NotificationsFragment extends MastodonToolbarFragment implements Sc new GetFollowRequests(null, 1).setCallback(new Callback<>() { @Override public void onSuccess(HeaderPaginationList accounts) { + if (getActivity() == null) return; getToolbar().getMenu().findItem(R.id.follow_requests).setVisible(!accounts.isEmpty()); } @@ -211,13 +216,13 @@ public class NotificationsFragment extends MastodonToolbarFragment implements Sc protected void updateToolbar(){ super.updateToolbar(); getToolbar().setOutlineProvider(null); + getToolbar().setOnClickListener(v->scrollToTop()); } private NotificationsListFragment getFragmentForPage(int page){ return switch(page){ case 0 -> allNotificationsFragment; case 1 -> mentionsFragment; - case 2 -> postsFragment; default -> throw new IllegalStateException("Unexpected value: "+page); }; } @@ -238,7 +243,7 @@ public class NotificationsFragment extends MastodonToolbarFragment implements Sc @Override public int getItemCount(){ - return 3; + return 2; } @Override 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 97ec6f770..3516efaed 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/NotificationsListFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/NotificationsListFragment.java @@ -2,8 +2,7 @@ package org.joinmastodon.android.fragments; import android.app.Activity; import android.os.Bundle; -import android.view.Menu; -import android.view.MenuInflater; +import android.text.TextUtils; import android.view.View; import com.squareup.otto.Subscribe; @@ -14,14 +13,20 @@ import org.joinmastodon.android.api.requests.markers.SaveMarkers; import org.joinmastodon.android.api.session.AccountSessionManager; import org.joinmastodon.android.events.PollUpdatedEvent; import org.joinmastodon.android.events.RemoveAccountPostsEvent; +import org.joinmastodon.android.model.Account; +import org.joinmastodon.android.model.Filter; import org.joinmastodon.android.model.Notification; import org.joinmastodon.android.model.PaginatedResponse; import org.joinmastodon.android.model.Status; import org.joinmastodon.android.ui.displayitems.AccountCardStatusDisplayItem; +import org.joinmastodon.android.ui.displayitems.AccountStatusDisplayItem; import org.joinmastodon.android.ui.displayitems.HeaderStatusDisplayItem; import org.joinmastodon.android.ui.displayitems.ImageStatusDisplayItem; import org.joinmastodon.android.ui.displayitems.StatusDisplayItem; +import org.joinmastodon.android.ui.displayitems.TextStatusDisplayItem; +import org.joinmastodon.android.ui.utils.DiscoverInfoBannerHelper; import org.joinmastodon.android.ui.utils.InsetStatusItemDecoration; +import org.joinmastodon.android.ui.utils.UiUtils; import org.parceler.Parcels; import java.util.ArrayList; @@ -41,6 +46,12 @@ public class NotificationsListFragment extends BaseStatusListFragment buildDisplayItems(Notification n){ + Account reportTarget = n.report == null ? null : n.report.targetAccount == null ? null : + n.report.targetAccount; String extraText=switch(n.type){ case FOLLOW -> getString(R.string.user_followed_you); case FOLLOW_REQUEST -> getString(R.string.user_sent_follow_request); @@ -78,10 +91,13 @@ public class NotificationsListFragment extends BaseStatusListFragment getString(R.string.notification_boosted); case FAVORITE -> getString(R.string.user_favorited); case POLL -> getString(R.string.poll_ended); + case UPDATE -> getString(R.string.sk_post_edited); + case SIGN_UP -> getString(R.string.sk_signed_up); + case REPORT -> getString(R.string.sk_reported); }; - HeaderStatusDisplayItem titleItem=extraText!=null ? new HeaderStatusDisplayItem(n.id, n.account, n.createdAt, this, accountID, null, extraText, n, null) : null; + HeaderStatusDisplayItem titleItem=extraText!=null ? new HeaderStatusDisplayItem(n.id, n.account, n.createdAt, this, accountID, n.status, extraText, n, null) : null; if(n.status!=null){ - ArrayList items=StatusDisplayItem.buildItems(this, n.status, accountID, n, knownAccounts, titleItem!=null, titleItem==null, n); + ArrayList items=StatusDisplayItem.buildItems(this, n.status, accountID, n, knownAccounts, titleItem!=null, titleItem==null, n, false, Filter.FilterContext.NOTIFICATIONS); if(titleItem!=null){ for(StatusDisplayItem item:items){ if(item instanceof ImageStatusDisplayItem imgItem){ @@ -93,8 +109,13 @@ public class NotificationsListFragment extends BaseStatusListFragment0 ? maxID : null, count, onlyMentions, onlyPosts, refreshing, new SimpleCallback<>(this){ @Override public void onSuccess(PaginatedResponse> result){ - if(getActivity()==null) - return; + if (getActivity() == null) return; if(refreshing) relationships.clear(); onDataLoaded(result.items.stream().filter(n->n.type!=null).collect(Collectors.toList()), !result.items.isEmpty()); @@ -163,6 +183,9 @@ public class NotificationsListFragment extends BaseStatusListFragment pinnedTimelines; + + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + pinnedTimelines = new ArrayList<>(GlobalUserPreferences.pinnedTimelines.getOrDefault(accountID, TimelineDefinition.DEFAULT_TIMELINES)); + } + + @Override + public void onCreateOptionsMenu(Menu menu, MenuInflater inflater) { + super.onCreateOptionsMenu(menu, inflater); + updatePinButton(menu.findItem(R.id.pin)); + } + + protected boolean isPinned() { + return pinnedTimelines.contains(makeTimelineDefinition()); + } + + protected void updatePinButton(MenuItem pin) { + boolean pinned = isPinned(); + pin.setIcon(pinned ? + R.drawable.ic_fluent_pin_24_filled : + R.drawable.ic_fluent_pin_24_regular); + pin.setTitle(pinned ? R.string.sk_unpin_timeline : R.string.sk_pin_timeline); + } + + protected abstract TimelineDefinition makeTimelineDefinition(); + + @Override + public boolean onOptionsItemSelected(MenuItem item) { + if (item.getItemId() == R.id.pin) { + togglePin(item); + return true; + } + return super.onOptionsItemSelected(item); + } + + protected void togglePin(MenuItem pin) { + onPinnedUpdated(true); + getToolbar().performHapticFeedback(HapticFeedbackConstants.CONTEXT_CLICK); + TimelineDefinition def = makeTimelineDefinition(); + boolean pinned = isPinned(); + if (pinned) pinnedTimelines.remove(def); + else pinnedTimelines.add(def); + Toast.makeText(getContext(), pinned ? R.string.sk_unpinned_timeline : R.string.sk_pinned_timeline, Toast.LENGTH_SHORT).show(); + GlobalUserPreferences.pinnedTimelines.put(accountID, pinnedTimelines); + GlobalUserPreferences.save(); + updatePinButton(pin); + } + + public void onPinnedUpdated(boolean pinned) {} +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/ProfileFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/ProfileFragment.java index a76d0a4da..0974350f4 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/ProfileFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/ProfileFragment.java @@ -1,26 +1,21 @@ package org.joinmastodon.android.fragments; -import static android.content.Context.CLIPBOARD_SERVICE; - import android.animation.Animator; import android.animation.AnimatorListenerAdapter; import android.animation.AnimatorSet; import android.animation.ObjectAnimator; import android.app.Activity; import android.app.Fragment; -import android.content.ClipData; -import android.content.ClipboardManager; -import android.content.Context; import android.content.Intent; import android.content.res.Configuration; +import android.graphics.Color; import android.graphics.Outline; import android.graphics.drawable.ColorDrawable; import android.graphics.drawable.Drawable; +import android.graphics.drawable.GradientDrawable; import android.net.Uri; import android.os.Build; import android.os.Bundle; -import android.os.VibrationEffect; -import android.os.Vibrator; import android.text.SpannableStringBuilder; import android.text.TextUtils; import android.text.style.ImageSpan; @@ -36,17 +31,16 @@ import android.view.ViewGroup; import android.view.ViewOutlineProvider; import android.view.ViewTreeObserver; import android.view.WindowInsets; +import android.view.inputmethod.InputMethodManager; import android.view.animation.TranslateAnimation; import android.widget.Button; import android.widget.EditText; import android.widget.FrameLayout; import android.widget.ImageButton; import android.widget.ImageView; -import android.widget.LinearLayout; import android.widget.ProgressBar; import android.widget.RelativeLayout; import android.widget.TextView; -import android.widget.Toast; import android.widget.Toolbar; import org.joinmastodon.android.GlobalUserPreferences; @@ -65,6 +59,7 @@ import org.joinmastodon.android.model.Account; import org.joinmastodon.android.model.AccountField; import org.joinmastodon.android.model.Attachment; import org.joinmastodon.android.model.Relationship; +import org.joinmastodon.android.ui.BetterItemAnimator; import org.joinmastodon.android.ui.SimpleViewHolder; import org.joinmastodon.android.ui.SingleImagePhotoViewerListener; import org.joinmastodon.android.ui.drawables.CoverOverlayGradientDrawable; @@ -73,8 +68,10 @@ import org.joinmastodon.android.ui.tabs.TabLayout; import org.joinmastodon.android.ui.tabs.TabLayoutMediator; import org.joinmastodon.android.ui.text.CustomEmojiSpan; 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.CoverImageView; +import org.joinmastodon.android.ui.views.LinkedTextView; import org.joinmastodon.android.ui.views.NestedRecyclerScrollView; import org.joinmastodon.android.ui.views.ProgressBarButton; import org.parceler.Parcels; @@ -88,6 +85,9 @@ import java.util.Collections; import java.util.List; import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.recyclerview.widget.ItemTouchHelper; +import androidx.recyclerview.widget.LinearLayoutManager; import androidx.recyclerview.widget.RecyclerView; import androidx.swiperefreshlayout.widget.SwipeRefreshLayout; import androidx.viewpager2.widget.ViewPager2; @@ -98,10 +98,17 @@ import me.grishka.appkit.api.SimpleCallback; import me.grishka.appkit.fragments.BaseRecyclerFragment; import me.grishka.appkit.fragments.LoaderFragment; import me.grishka.appkit.fragments.OnBackPressedListener; +import me.grishka.appkit.imageloader.ImageLoaderRecyclerAdapter; +import me.grishka.appkit.imageloader.ImageLoaderViewHolder; +import me.grishka.appkit.imageloader.ListImageLoaderWrapper; +import me.grishka.appkit.imageloader.RecyclerViewDelegate; import me.grishka.appkit.imageloader.ViewImageLoader; +import me.grishka.appkit.imageloader.requests.ImageLoaderRequest; import me.grishka.appkit.imageloader.requests.UrlImageLoaderRequest; +import me.grishka.appkit.utils.BindableViewHolder; import me.grishka.appkit.utils.CubicBezierInterpolator; import me.grishka.appkit.utils.V; +import me.grishka.appkit.views.UsableRecyclerView; public class ProfileFragment extends LoaderFragment implements OnBackPressedListener, ScrollableToTop{ private static final int AVATAR_RESULT=722; @@ -109,23 +116,24 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList private ImageView avatar; private CoverImageView cover; - private View avatarBorder; + private View avatarBorder, nameWrap; private TextView name, username, bio, followersCount, followersLabel, followingCount, followingLabel, postsCount, postsLabel; private ProgressBarButton actionButton, notifyButton; private ViewPager2 pager; private NestedRecyclerScrollView scrollView; private AccountTimelineFragment postsFragment, postsWithRepliesFragment, pinnedPostsFragment, mediaFragment; - private ProfileAboutFragment aboutFragment; +// private ProfileAboutFragment aboutFragment; private TabLayout tabbar; private SwipeRefreshLayout refreshLayout; private CoverOverlayGradientDrawable coverGradient=new CoverOverlayGradientDrawable(); private float titleTransY; - private View postsBtn, followersBtn, followingBtn; + private View postsBtn, followersBtn, followingBtn, profileCounters; private EditText nameEdit, bioEdit; private ProgressBar actionProgress, notifyProgress; private FrameLayout[] tabViews; private TabLayoutMediator tabLayoutMediator; private TextView followsYouView; + private ViewGroup rolesView; private Account account; private String accountID; @@ -144,6 +152,16 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList private boolean editModeLoading; protected int scrollDiff = 0; + private static final int MAX_FIELDS=4; + + // from ProfileAboutFragment + public UsableRecyclerView list; + private List metadataListData=Collections.emptyList(); + private MetadataAdapter adapter; + private ItemTouchHelper dragHelper=new ItemTouchHelper(new ReorderCallback()); + private RecyclerView.ViewHolder draggedViewHolder; + private ListImageLoaderWrapper imgLoader; + public ProfileFragment(){ super(R.layout.loader_fragment_overlay_toolbar); } @@ -188,8 +206,10 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList cover=content.findViewById(R.id.cover); avatarBorder=content.findViewById(R.id.avatar_border); name=content.findViewById(R.id.name); + nameWrap=content.findViewById(R.id.name_wrap); username=content.findViewById(R.id.username); bio=content.findViewById(R.id.bio); + profileCounters=content.findViewById(R.id.profile_counters); followersCount=content.findViewById(R.id.followers_count); followersLabel=content.findViewById(R.id.followers_label); followersBtn=content.findViewById(R.id.followers_btn); @@ -211,6 +231,8 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList notifyProgress=content.findViewById(R.id.notify_progress); fab=content.findViewById(R.id.fab); followsYouView=content.findViewById(R.id.follows_you); + list=content.findViewById(R.id.metadata); + rolesView=content.findViewById(R.id.roles); avatar.setOutlineProvider(new ViewOutlineProvider(){ @Override @@ -230,7 +252,7 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList } }; - tabViews=new FrameLayout[5]; + tabViews=new FrameLayout[4]; for(int i=0;i(this){ @Override public void onSuccess(Account result){ + if (getActivity() == null) return; account=result; isOwnProfile=AccountSessionManager.getInstance().isSelf(accountID, account); bindHeaderView(); @@ -362,8 +393,8 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList postsWithRepliesFragment=AccountTimelineFragment.newInstance(accountID, account, GetAccountStatuses.Filter.INCLUDE_REPLIES, false); pinnedPostsFragment=AccountTimelineFragment.newInstance(accountID, account, GetAccountStatuses.Filter.PINNED, false); mediaFragment=AccountTimelineFragment.newInstance(accountID, account, GetAccountStatuses.Filter.MEDIA, false); - aboutFragment=new ProfileAboutFragment(); - aboutFragment.setFields(fields); +// aboutFragment=new ProfileAboutFragment(); + setFields(fields); } pager.getAdapter().notifyDataSetChanged(); super.dataLoaded(); @@ -456,6 +487,19 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList name.setText(ssb); setTitle(ssb); + if (account.roles != null && !account.roles.isEmpty()) { + rolesView.setVisibility(View.VISIBLE); + rolesView.removeAllViews(); + name.setPadding(0, 0, V.dp(12), 0); + for (Account.Role role : account.roles) { + TextView roleText = new TextView(getActivity(), null, 0, R.style.role_label); + roleText.setText(role.name); + GradientDrawable bg = (GradientDrawable) roleText.getBackground().mutate(); + bg.setStroke(V.dp(2), Color.parseColor(role.color)); + rolesView.addView(roleText); + } + } + boolean isSelf=AccountSessionManager.getInstance().isSelf(accountID, account); if(account.locked){ @@ -523,9 +567,7 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList fields.add(field); } - if(aboutFragment!=null){ - aboutFragment.setFields(fields); - } + setFields(fields); } private void updateToolbar(){ @@ -684,6 +726,7 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList } private void updateRelationship(){ + if (getActivity() == null) return; invalidateOptionsMenu(); actionButton.setVisibility(View.VISIBLE); notifyButton.setVisibility(relationship.following ? View.VISIBLE : View.GONE); @@ -693,7 +736,7 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList notifyProgress.setIndeterminateTintList(notifyButton.getTextColors()); followsYouView.setVisibility(relationship.followedBy ? View.VISIBLE : View.GONE); notifyButton.setSelected(relationship.notifying); - if (getActivity() != null) notifyButton.setContentDescription(getString(relationship.notifying ? R.string.sk_user_post_notifications_on : R.string.sk_user_post_notifications_off, '@'+account.username)); + notifyButton.setContentDescription(getString(relationship.notifying ? R.string.sk_user_post_notifications_on : R.string.sk_user_post_notifications_off, '@'+account.username)); } public ImageButton getFab() { @@ -720,8 +763,8 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList coverGradient.setTopOffset(scrollY); cover.invalidate(); titleTransY=getToolbar().getHeight(); - if(scrollY>name.getTop()-topBarsH){ - titleTransY=Math.max(0f, titleTransY-(scrollY-(name.getTop()-topBarsH))); + if(scrollY>nameWrap.getTop()-topBarsH){ + titleTransY=Math.max(0f, titleTransY-(scrollY-(nameWrap.getTop()-topBarsH))); } if(toolbarTitleView!=null){ toolbarTitleView.setTranslationY(titleTransY); @@ -768,7 +811,7 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList case 1 -> postsWithRepliesFragment; case 2 -> pinnedPostsFragment; case 3 -> mediaFragment; - case 4 -> aboutFragment; +// case 4 -> aboutFragment; default -> throw new IllegalStateException(); }; } @@ -810,8 +853,7 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList @Override public void onSuccess(Account result){ editModeLoading=false; - if(getActivity()==null) - return; + if (getActivity() == null) return; enterEditMode(result); setActionProgressVisible(false); } @@ -819,8 +861,7 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList @Override public void onError(ErrorResponse error){ editModeLoading=false; - if(getActivity()==null) - return; + if (getActivity() == null) return; error.showToast(getActivity()); setActionProgressVisible(false); } @@ -835,16 +876,12 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList invalidateOptionsMenu(); pager.setUserInputEnabled(false); actionButton.setText(R.string.done); - pager.setCurrentItem(4); ArrayList animators=new ArrayList<>(); - for(int i=0;i animators=new ArrayList<>(); actionButton.setText(R.string.edit_profile); - for(int i=0;i(){ @Override public void onSuccess(Account result){ account=result; AccountSessionManager.getInstance().updateAccountInfo(accountID, account); + if (getActivity() == null) return; exitEditMode(); setActionProgressVisible(false); } @@ -1087,4 +1128,227 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList return position; } } + + // from ProfileAboutFragment + public void setFields(ArrayList fields){ + metadataListData=fields; + if (isInEditMode) { + isInEditMode=false; + dragHelper.attachToRecyclerView(null); + } + if (adapter != null) adapter.notifyDataSetChanged(); + } + + private class MetadataAdapter extends UsableRecyclerView.Adapter implements ImageLoaderRecyclerAdapter { + public MetadataAdapter(){ + super(imgLoader); + } + + @NonNull + @Override + public BaseViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType){ + return switch(viewType){ + case 0 -> new AboutViewHolder(); + case 1 -> new EditableAboutViewHolder(); + case 2 -> new AddRowViewHolder(); + default -> throw new IllegalStateException("Unexpected value: "+viewType); + }; + } + + @Override + public void onBindViewHolder(BaseViewHolder holder, int position){ + if(position { + public BaseViewHolder(int layout){ + super(getActivity(), layout, list); + } + } + + private class AboutViewHolder extends BaseViewHolder implements ImageLoaderViewHolder { + private TextView title; + private LinkedTextView value; + + public AboutViewHolder(){ + super(R.layout.item_profile_about); + title=findViewById(R.id.title); + value=findViewById(R.id.value); + } + + @Override + public void onBind(AccountField item){ + title.setText(item.parsedName); + value.setText(item.parsedValue); + if(item.verifiedAt!=null){ + int textColor=UiUtils.isDarkTheme() ? 0xFF89bb9c : 0xFF5b8e63; + value.setTextColor(textColor); + value.setLinkTextColor(textColor); + Drawable check=getResources().getDrawable(R.drawable.ic_fluent_checkmark_24_regular, getActivity().getTheme()).mutate(); + check.setTint(textColor); + value.setCompoundDrawablesRelativeWithIntrinsicBounds(null, null, check, null); + }else{ + value.setTextColor(UiUtils.getThemeColor(getActivity(), android.R.attr.textColorPrimary)); + value.setLinkTextColor(UiUtils.getThemeColor(getActivity(), android.R.attr.colorAccent)); + value.setCompoundDrawables(null, null, null, null); + } + } + + @Override + public void setImage(int index, Drawable image){ + CustomEmojiSpan span=index>=item.nameEmojis.length ? item.valueEmojis[index-item.nameEmojis.length] : item.nameEmojis[index]; + span.setDrawable(image); + title.invalidate(); + value.invalidate(); + } + + @Override + public void clearImage(int index){ + setImage(index, null); + } + } + + private class EditableAboutViewHolder extends BaseViewHolder { + private EditText title; + private EditText value; + + public EditableAboutViewHolder(){ + super(R.layout.item_profile_about_editable); + title=findViewById(R.id.title); + value=findViewById(R.id.value); + findViewById(R.id.dragger_thingy).setOnLongClickListener(v->{ + dragHelper.startDrag(this); + return true; + }); + title.addTextChangedListener(new SimpleTextWatcher(e->item.name=e.toString())); + value.addTextChangedListener(new SimpleTextWatcher(e->item.value=e.toString())); + findViewById(R.id.remove_row_btn).setOnClickListener(this::onRemoveRowClick); + } + + @Override + public void onBind(AccountField item){ + title.setText(item.name); + value.setText(item.value); + } + + private void onRemoveRowClick(View v){ + int pos=getAbsoluteAdapterPosition(); + metadataListData.remove(pos); + adapter.notifyItemRemoved(pos); + for(int i=0;itoPosition;i--) { + Collections.swap(metadataListData, i, i-1); + } + } + adapter.notifyItemMoved(fromPosition, toPosition); + ((BindableViewHolder)viewHolder).rebind(); + ((BindableViewHolder)target).rebind(); + return true; + } + + @Override + public void onSwiped(@NonNull RecyclerView.ViewHolder viewHolder, int direction){ + + } + + @Override + public void onSelectedChanged(@Nullable RecyclerView.ViewHolder viewHolder, int actionState){ + super.onSelectedChanged(viewHolder, actionState); + if(actionState==ItemTouchHelper.ACTION_STATE_DRAG){ + viewHolder.itemView.setTag(R.id.item_touch_helper_previous_elevation, viewHolder.itemView.getElevation()); // prevents the default behavior of changing elevation in onDraw() + viewHolder.itemView.animate().translationZ(V.dp(1)).setDuration(200).setInterpolator(CubicBezierInterpolator.DEFAULT).start(); + draggedViewHolder=viewHolder; + } + } + + @Override + public void clearView(@NonNull RecyclerView recyclerView, @NonNull RecyclerView.ViewHolder viewHolder){ + super.clearView(recyclerView, viewHolder); + viewHolder.itemView.animate().translationZ(0).setDuration(100).setInterpolator(CubicBezierInterpolator.DEFAULT).start(); + draggedViewHolder=null; + } + + @Override + public boolean isLongPressDragEnabled(){ + return false; + } + } } diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/ScheduledStatusListFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/ScheduledStatusListFragment.java index 35525a406..eed44b9e8 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/ScheduledStatusListFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/ScheduledStatusListFragment.java @@ -28,11 +28,11 @@ import me.grishka.appkit.api.SimpleCallback; public class ScheduledStatusListFragment extends BaseStatusListFragment { private String nextMaxID; - private ImageButton fab; private static final int SCHEDULED_STATUS_LIST_OPENED = 161; - public ScheduledStatusListFragment() { - setListLayoutId(R.layout.recycler_fragment_with_fab); + @Override + protected boolean withComposeButton() { + return true; } @Override @@ -56,20 +56,30 @@ public class ScheduledStatusListFragment extends BaseStatusListFragment Nav.go(getActivity(), ComposeFragment.class, args)); - fab.setOnLongClickListener(v -> UiUtils.pickAccountForCompose(getActivity(), accountID, args)); + Nav.go(getActivity(), ComposeFragment.class, args); + } + + @Override + protected boolean onFabLongClick(View v) { + Bundle args=new Bundle(); + args.putString("account", accountID); + args.putSerializable("scheduledAt", CreateStatus.getDraftInstant()); + return UiUtils.pickAccountForCompose(getActivity(), accountID, args); + } + + @Override + public void onViewCreated(View view, Bundle savedInstanceState) { + super.onViewCreated(view, savedInstanceState); if (getArguments().getBoolean("hide_fab", false)) fab.setVisibility(View.GONE); } @Override protected List buildDisplayItems(ScheduledStatus s) { - return StatusDisplayItem.buildItems(this, s.toStatus(), accountID, s, knownAccounts, false, false, null); + return StatusDisplayItem.buildItems(this, s.toStatus(), accountID, s, knownAccounts, false, false, null, true); } @Override @@ -109,6 +119,7 @@ public class ScheduledStatusListFragment extends BaseStatusListFragment items=new ArrayList<>(); private ThemeItem themeItem; private NotificationPolicyItem notificationPolicyItem; + private SwitchItem showNewPostsButtonItem, glitchModeItem; private String accountID; private boolean needUpdateNotificationSettings; private boolean needAppRestart; @@ -165,11 +169,6 @@ public class SettingsFragment extends MastodonToolbarFragment{ })); items.add(new HeaderItem(R.string.settings_behavior)); - items.add(new SwitchItem(R.string.sk_settings_show_federated_timeline, R.drawable.ic_fluent_earth_24_regular, GlobalUserPreferences.showFederatedTimeline, i->{ - GlobalUserPreferences.showFederatedTimeline=i.checked; - GlobalUserPreferences.save(); - needAppRestart=true; - })); items.add(new SwitchItem(R.string.settings_gif, R.drawable.ic_fluent_gif_24_regular, GlobalUserPreferences.playGifs, i->{ GlobalUserPreferences.playGifs=i.checked; GlobalUserPreferences.save(); @@ -196,6 +195,59 @@ public class SettingsFragment extends MastodonToolbarFragment{ GlobalUserPreferences.save(); needAppRestart=true; })); + items.add(new SwitchItem(R.string.sk_settings_disable_alt_text_reminder, R.drawable.ic_fluent_image_alt_text_24_regular, GlobalUserPreferences.disableAltTextReminder, i->{ + GlobalUserPreferences.disableAltTextReminder=i.checked; + GlobalUserPreferences.save(); + })); + items.add(new SwitchItem(R.string.sk_settings_single_notification, R.drawable.ic_fluent_convert_range_24_regular, GlobalUserPreferences.keepOnlyLatestNotification, i->{ + GlobalUserPreferences.keepOnlyLatestNotification=i.checked; + GlobalUserPreferences.save(); + })); + items.add(new SwitchItem(R.string.sk_settings_prefix_reply_cw_with_re, R.drawable.ic_fluent_arrow_reply_24_regular, GlobalUserPreferences.prefixRepliesWithRe, i->{ + GlobalUserPreferences.prefixRepliesWithRe=i.checked; + GlobalUserPreferences.save(); + })); + + items.add(new HeaderItem(R.string.sk_timelines)); + items.add(new SwitchItem(R.string.sk_settings_show_replies, R.drawable.ic_fluent_chat_multiple_24_regular, GlobalUserPreferences.showReplies, i->{ + GlobalUserPreferences.showReplies=i.checked; + GlobalUserPreferences.save(); + })); + items.add(new SwitchItem(R.string.sk_settings_show_boosts, R.drawable.ic_fluent_arrow_repeat_all_24_regular, GlobalUserPreferences.showBoosts, i->{ + GlobalUserPreferences.showBoosts=i.checked; + GlobalUserPreferences.save(); + })); + items.add(new SwitchItem(R.string.sk_settings_load_new_posts, R.drawable.ic_fluent_arrow_sync_24_regular, GlobalUserPreferences.loadNewPosts, i->{ + GlobalUserPreferences.loadNewPosts=i.checked; + showNewPostsButtonItem.enabled = i.checked; + if (!i.checked) { + GlobalUserPreferences.showNewPostsButton = false; + showNewPostsButtonItem.checked = false; + } + if (list.findViewHolderForAdapterPosition(items.indexOf(showNewPostsButtonItem)) instanceof SwitchViewHolder svh) svh.rebind(); + GlobalUserPreferences.save(); + })); + items.add(showNewPostsButtonItem = new SwitchItem(R.string.sk_settings_see_new_posts_button, R.drawable.ic_fluent_arrow_up_24_regular, GlobalUserPreferences.showNewPostsButton, i->{ + GlobalUserPreferences.showNewPostsButton=i.checked; + GlobalUserPreferences.save(); + })); + items.add(new SwitchItem(R.string.sk_settings_show_alt_indicator, R.drawable.ic_fluent_scan_text_24_regular, GlobalUserPreferences.showAltIndicator, i->{ + GlobalUserPreferences.showAltIndicator=i.checked; + GlobalUserPreferences.save(); + })); + items.add(new SwitchItem(R.string.sk_settings_show_no_alt_indicator, R.drawable.ic_fluent_important_24_regular, GlobalUserPreferences.showNoAltIndicator, i->{ + GlobalUserPreferences.showNoAltIndicator=i.checked; + GlobalUserPreferences.save(); + })); + items.add(new SwitchItem(R.string.sk_settings_collapse_long_posts, R.drawable.ic_fluent_chevron_down_24_filled, GlobalUserPreferences.collapseLongPosts, i->{ + GlobalUserPreferences.collapseLongPosts=i.checked; + GlobalUserPreferences.save(); + })); + items.add(new SwitchItem(R.string.sk_spectator_mode, R.drawable.ic_fluent_eye_24_regular, GlobalUserPreferences.spectatorMode, i->{ + GlobalUserPreferences.spectatorMode=i.checked; + GlobalUserPreferences.save(); + needAppRestart=true; + })); items.add(new SwitchItem(R.string.sk_settings_translate_only_opened, R.drawable.ic_fluent_translate_24_regular, GlobalUserPreferences.translateButtonOpenedOnly, i->{ GlobalUserPreferences.translateButtonOpenedOnly=i.checked; GlobalUserPreferences.save(); @@ -206,32 +258,18 @@ public class SettingsFragment extends MastodonToolbarFragment{ R.string.sk_settings_translation_availability_note_available : R.string.sk_settings_translation_availability_note_unavailable, instanceName))); - items.add(new HeaderItem(R.string.home_timeline)); - items.add(new SwitchItem(R.string.sk_settings_show_replies, R.drawable.ic_fluent_chat_multiple_24_regular, GlobalUserPreferences.showReplies, i->{ - GlobalUserPreferences.showReplies=i.checked; - GlobalUserPreferences.save(); - })); - items.add(new SwitchItem(R.string.sk_settings_show_boosts, R.drawable.ic_fluent_arrow_repeat_all_24_regular, GlobalUserPreferences.showBoosts, i->{ - GlobalUserPreferences.showBoosts=i.checked; - GlobalUserPreferences.save(); - })); - items.add(new SwitchItem(R.string.sk_settings_load_new_posts, R.drawable.ic_fluent_arrow_up_24_regular, GlobalUserPreferences.loadNewPosts, i->{ - GlobalUserPreferences.loadNewPosts=i.checked; - GlobalUserPreferences.save(); - })); - items.add(new HeaderItem(R.string.settings_notifications)); items.add(notificationPolicyItem=new NotificationPolicyItem()); PushSubscription pushSubscription=getPushSubscription(); - items.add(new SwitchItem(R.string.notify_favorites, R.drawable.ic_fluent_star_24_regular, pushSubscription.alerts.favourite, i->onNotificationsChanged(PushNotification.Type.FAVORITE, i.checked))); - items.add(new SwitchItem(R.string.notify_follow, R.drawable.ic_fluent_person_add_24_regular, pushSubscription.alerts.follow, i->onNotificationsChanged(PushNotification.Type.FOLLOW, i.checked))); - items.add(new SwitchItem(R.string.notify_reblog, R.drawable.ic_fluent_arrow_repeat_all_24_regular, pushSubscription.alerts.reblog, i->onNotificationsChanged(PushNotification.Type.REBLOG, i.checked))); - items.add(new SwitchItem(R.string.notify_mention, R.drawable.ic_fluent_mention_24_regular, pushSubscription.alerts.mention, i->onNotificationsChanged(PushNotification.Type.MENTION, i.checked))); - items.add(new SwitchItem(R.string.sk_notify_posts, R.drawable.ic_fluent_alert_24_regular, pushSubscription.alerts.status, i->onNotificationsChanged(PushNotification.Type.STATUS, i.checked))); - items.add(new SwitchItem(R.string.sk_settings_single_notification, R.drawable.ic_fluent_convert_range_24_regular, GlobalUserPreferences.keepOnlyLatestNotification, i->{ - GlobalUserPreferences.keepOnlyLatestNotification=i.checked; - GlobalUserPreferences.save(); - })); + boolean switchEnabled=pushSubscription.policy!=PushSubscription.Policy.NONE; + + items.add(new SwitchItem(R.string.notify_favorites, R.drawable.ic_fluent_star_24_regular, pushSubscription.alerts.favourite, i->onNotificationsChanged(PushNotification.Type.FAVORITE, i.checked), switchEnabled)); + items.add(new SwitchItem(R.string.notify_follow, R.drawable.ic_fluent_person_add_24_regular, pushSubscription.alerts.follow, i->onNotificationsChanged(PushNotification.Type.FOLLOW, i.checked), switchEnabled)); + items.add(new SwitchItem(R.string.notify_reblog, R.drawable.ic_fluent_arrow_repeat_all_24_regular, pushSubscription.alerts.reblog, i->onNotificationsChanged(PushNotification.Type.REBLOG, i.checked), switchEnabled)); + items.add(new SwitchItem(R.string.notify_mention, R.drawable.ic_fluent_mention_24_regular, pushSubscription.alerts.mention, i->onNotificationsChanged(PushNotification.Type.MENTION, i.checked), switchEnabled)); + items.add(new SwitchItem(R.string.sk_notify_posts, R.drawable.ic_fluent_chat_24_regular, pushSubscription.alerts.status, i->onNotificationsChanged(PushNotification.Type.STATUS, i.checked), switchEnabled)); + items.add(new SwitchItem(R.string.sk_notify_update, R.drawable.ic_fluent_history_24_regular, pushSubscription.alerts.update, i->onNotificationsChanged(PushNotification.Type.UPDATE, i.checked), switchEnabled)); + items.add(new SwitchItem(R.string.sk_notify_poll_results, R.drawable.ic_fluent_poll_24_regular, pushSubscription.alerts.poll, i->onNotificationsChanged(PushNotification.Type.POLL, i.checked), switchEnabled)); items.add(new HeaderItem(R.string.settings_account)); items.add(new TextItem(R.string.sk_settings_profile, ()->UiUtils.launchWebBrowser(getActivity(), "https://"+session.domain+"/settings/profile"), R.drawable.ic_fluent_open_24_regular)); @@ -249,20 +287,65 @@ public class SettingsFragment extends MastodonToolbarFragment{ items.add(new TextItem(R.string.settings_tos, ()->UiUtils.launchWebBrowser(getActivity(), "https://"+session.domain+"/terms"), R.drawable.ic_fluent_open_24_regular)); items.add(new TextItem(R.string.settings_privacy_policy, ()->UiUtils.launchWebBrowser(getActivity(), "https://"+session.domain+"/terms"), R.drawable.ic_fluent_open_24_regular)); items.add(new TextItem(R.string.log_out, this::confirmLogOut, R.drawable.ic_fluent_sign_out_24_regular)); + if (!TextUtils.isEmpty(instance.version)) items.add(new SmallTextItem(getString(R.string.sk_settings_server_version, instance.version))); + + items.add(new HeaderItem(R.string.sk_instance_features)); + items.add(new SwitchItem(R.string.sk_settings_support_local_only, 0, GlobalUserPreferences.accountsWithLocalOnlySupport.contains(accountID), i->{ + glitchModeItem.enabled = i.checked; + if (i.checked) { + GlobalUserPreferences.accountsWithLocalOnlySupport.add(accountID); + if (instance.pleroma == null) GlobalUserPreferences.accountsInGlitchMode.add(accountID); + } else { + GlobalUserPreferences.accountsWithLocalOnlySupport.remove(accountID); + GlobalUserPreferences.accountsInGlitchMode.remove(accountID); + } + glitchModeItem.checked = GlobalUserPreferences.accountsInGlitchMode.contains(accountID); + if (list.findViewHolderForAdapterPosition(items.indexOf(glitchModeItem)) instanceof SwitchViewHolder svh) svh.rebind(); + GlobalUserPreferences.save(); + })); + items.add(new SmallTextItem(getString(R.string.sk_settings_local_only_explanation))); + items.add(glitchModeItem = new SwitchItem(R.string.sk_settings_glitch_instance, 0, GlobalUserPreferences.accountsInGlitchMode.contains(accountID), i->{ + if (i.checked) { + GlobalUserPreferences.accountsInGlitchMode.add(accountID); + } else { + GlobalUserPreferences.accountsInGlitchMode.remove(accountID); + } + GlobalUserPreferences.save(); + })); + glitchModeItem.enabled = GlobalUserPreferences.accountsWithLocalOnlySupport.contains(accountID); + items.add(new SmallTextItem(getString(R.string.sk_settings_glitch_mode_explanation))); items.add(new HeaderItem(R.string.sk_settings_about)); items.add(new TextItem(R.string.sk_settings_contribute, ()->UiUtils.launchWebBrowser(getActivity(), "https://github.com/sk22/megalodon"), R.drawable.ic_fluent_open_24_regular)); items.add(new TextItem(R.string.sk_settings_donate, ()->UiUtils.launchWebBrowser(getActivity(), "https://ko-fi.com/xsk22"), R.drawable.ic_fluent_heart_24_regular)); - if (GithubSelfUpdater.needSelfUpdating()) { - checkForUpdateItem = new TextItem(R.string.sk_check_for_update, GithubSelfUpdater.getInstance()::checkForUpdates); - items.add(checkForUpdateItem); - } - clearImageCacheItem = new TextItem(R.string.settings_clear_cache, UiUtils.formatFileSize(getContext(), imageCache.getDiskCache().size(), true), this::clearImageCache, 0); + LruCache cache = imageCache == null ? null : imageCache.getLruCache(); + clearImageCacheItem = new TextItem(R.string.settings_clear_cache, UiUtils.formatFileSize(getContext(), cache != null ? cache.size() : 0, true), this::clearImageCache, 0); items.add(clearImageCacheItem); items.add(new TextItem(R.string.sk_clear_recent_languages, ()->UiUtils.showConfirmationAlert(getActivity(), R.string.sk_clear_recent_languages, R.string.sk_confirm_clear_recent_languages, R.string.clear, ()->{ GlobalUserPreferences.recentLanguages.remove(accountID); GlobalUserPreferences.save(); }))); + if (GithubSelfUpdater.needSelfUpdating()) { + items.add(new SwitchItem(R.string.sk_updater_enable_pre_releases, 0, GlobalUserPreferences.enablePreReleases, i->{ + GlobalUserPreferences.enablePreReleases=i.checked; + GlobalUserPreferences.save(); + })); + checkForUpdateItem = new TextItem(R.string.sk_check_for_update, GithubSelfUpdater.getInstance()::checkForUpdates); + items.add(checkForUpdateItem); + } + + if(BuildConfig.DEBUG){ + items.add(new RedHeaderItem("Debug options")); + items.add(new TextItem("Test e-mail confirmation flow", ()->{ + AccountSession sess=AccountSessionManager.getInstance().getAccount(accountID); + sess.activated=false; + sess.activationInfo=new AccountActivationInfo("test@email", System.currentTimeMillis()); + Bundle args=new Bundle(); + args.putString("account", accountID); + args.putBoolean("debug", true); + Nav.goClearingStack(getActivity(), AccountActivationFragment.class, args); + })); + } items.add(new FooterItem(getString(R.string.sk_settings_app_version, BuildConfig.VERSION_NAME, BuildConfig.VERSION_CODE))); } @@ -317,11 +400,7 @@ public class SettingsFragment extends MastodonToolbarFragment{ if(needUpdateNotificationSettings && PushSubscriptionManager.arePushNotificationsAvailable()){ AccountSessionManager.getInstance().getAccount(accountID).getPushSubscriptionManager().updatePushSettings(pushSubscription); } - if(needAppRestart){ - Intent intent = Intent.makeRestartActivityTask(MastodonApp.context.getPackageManager().getLaunchIntentForPackage(MastodonApp.context.getPackageName()).getComponent()); - MastodonApp.context.startActivity(intent); - Runtime.getRuntime().exit(0); - } + if(needAppRestart) UiUtils.restartApp(); } @Override @@ -423,8 +502,10 @@ public class SettingsFragment extends MastodonToolbarFragment{ case FAVORITE -> subscription.alerts.favourite=enabled; case FOLLOW -> subscription.alerts.follow=enabled; case REBLOG -> subscription.alerts.reblog=enabled; - case MENTION -> subscription.alerts.mention=subscription.alerts.poll=enabled; + case MENTION -> subscription.alerts.mention=enabled; + case POLL -> subscription.alerts.poll=enabled; case STATUS -> subscription.alerts.status=enabled; + case UPDATE -> subscription.alerts.update=enabled; } needUpdateNotificationSettings=true; } @@ -443,9 +524,13 @@ public class SettingsFragment extends MastodonToolbarFragment{ list.getAdapter().notifyItemChanged(index); } if((prevPolicy==PushSubscription.Policy.NONE)!=(policy==PushSubscription.Policy.NONE)){ + boolean newState=policy!=PushSubscription.Policy.NONE; + for(PushNotification.Type value : PushNotification.Type.values()){ + onNotificationsChanged(value, newState); + } index++; while(items.get(index) instanceof SwitchItem si){ - si.enabled=si.checked=policy!=PushSubscription.Policy.NONE; + si.enabled=si.checked=newState; RecyclerView.ViewHolder holder=list.findViewHolderForAdapterPosition(index); if(holder!=null) ((BindableViewHolder)holder).rebind(); @@ -485,6 +570,7 @@ public class SettingsFragment extends MastodonToolbarFragment{ } private void onLoggedOut(){ + if (getActivity() == null) return; AccountSessionManager.getInstance().removeAccount(accountID); getActivity().finish(); Intent intent=new Intent(getActivity(), MainActivity.class); @@ -538,7 +624,7 @@ public class SettingsFragment extends MastodonToolbarFragment{ this.text=getString(text); } - public HeaderItem(String text) { + public HeaderItem(String text){ this.text=text; } @@ -562,7 +648,7 @@ public class SettingsFragment extends MastodonToolbarFragment{ this.onChanged=onChanged; } - public SwitchItem(@StringRes int text, int icon, boolean checked, Consumer onChanged, boolean enabled){ + public SwitchItem(@StringRes int text, @DrawableRes int icon, boolean checked, Consumer onChanged, boolean enabled){ this.text=getString(text); this.icon=icon; this.checked=checked; @@ -649,6 +735,11 @@ public class SettingsFragment extends MastodonToolbarFragment{ this.secondaryText = secondaryText; } + public TextItem(String text, Runnable onClick){ + this.text=text; + this.onClick=onClick; + } + @Override public int getViewType(){ return 4; @@ -661,6 +752,10 @@ public class SettingsFragment extends MastodonToolbarFragment{ super(text); } + public RedHeaderItem(String text){ + super(text); + } + @Override public int getViewType(){ return 5; @@ -754,7 +849,12 @@ public class SettingsFragment extends MastodonToolbarFragment{ @Override public void onBind(SwitchItem item){ text.setText(item.text); - icon.setImageResource(item.icon); + if (item.icon == 0) { + icon.setVisibility(View.GONE); + } else { + icon.setVisibility(View.VISIBLE); + icon.setImageResource(item.icon); + } checkbox.setChecked(item.checked && item.enabled); checkbox.setEnabled(item.enabled); } @@ -928,19 +1028,19 @@ public class SettingsFragment extends MastodonToolbarFragment{ private class SmallTextViewHolder extends BindableViewHolder { private final TextView text; -; public SmallTextViewHolder(){ super(getActivity(), R.layout.item_settings_text, list); text = itemView.findViewById(R.id.text); + text.setTextColor(UiUtils.getThemeColor(getActivity(), android.R.attr.textColorSecondary)); + text.setLayoutParams(new LinearLayout.LayoutParams(LinearLayout.LayoutParams.WRAP_CONTENT, LinearLayout.LayoutParams.WRAP_CONTENT)); + text.setTextSize(TypedValue.COMPLEX_UNIT_SP, 14); + text.setPaddingRelative(text.getPaddingStart(), 0, text.getPaddingEnd(), text.getPaddingBottom()); } @Override public void onBind(SmallTextItem item){ text.setText(item.text); - text.setTextColor(UiUtils.getThemeColor(getActivity(), android.R.attr.textColorSecondary)); - text.setLayoutParams(new LinearLayout.LayoutParams(LinearLayout.LayoutParams.WRAP_CONTENT, LinearLayout.LayoutParams.WRAP_CONTENT)); - text.setTextSize(TypedValue.COMPLEX_UNIT_SP, 14); } } 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 35d691b60..b11047400 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/SplashFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/SplashFragment.java @@ -173,16 +173,7 @@ public class SplashFragment extends AppKitFragment{ TextView title=new TextView(getActivity()); title.setTextAppearance(R.style.m3_headline_medium); title.setText(switch(page){ - case 0 -> { - String src=getString(R.string.welcome_page1_title); - SpannableString ss=new SpannableString(src); - int start=src.indexOf("{logo}"); - if(start!=-1){ - LogoSpan span=new LogoSpan(getResources().getDrawable(R.drawable.splash_logo, getActivity().getTheme())); - ss.setSpan(span, start, start+6, 0); - } - yield ss; - } + case 0 -> getString(R.string.welcome_page1_title); case 1 -> getString(R.string.welcome_page2_title); case 2 -> getString(R.string.welcome_page3_title); default -> throw new IllegalStateException("Unexpected value: "+page); @@ -204,26 +195,4 @@ public class SplashFragment extends AppKitFragment{ ll.addView(text, new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)); } } - - private class LogoSpan extends ReplacementSpan{ - private final Drawable drawable; - - private LogoSpan(Drawable drawable){ - this.drawable=drawable; - } - - @Override - public int getSize(@NonNull Paint paint, CharSequence text, int start, int end, @Nullable Paint.FontMetricsInt fm){ - return drawable.getIntrinsicWidth(); - } - - @Override - public void draw(@NonNull Canvas canvas, CharSequence text, int start, int end, float x, int top, int y, int bottom, @NonNull Paint paint){ - drawable.setBounds(0, 0, drawable.getIntrinsicWidth(), drawable.getIntrinsicHeight()); - canvas.save(); - canvas.translate(x, y-V.dp(20)); - drawable.draw(canvas); - canvas.restore(); - } - } } diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/StatusEditHistoryFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/StatusEditHistoryFragment.java index 37649f7af..aa2a59c46 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/StatusEditHistoryFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/StatusEditHistoryFragment.java @@ -46,6 +46,7 @@ public class StatusEditHistoryFragment extends StatusListFragment{ @Override public void onSuccess(List result){ Collections.sort(result, Comparator.comparing((Status s)->s.createdAt).reversed()); + if (getActivity() == null) return; onDataLoaded(result, false); } }) @@ -139,7 +140,8 @@ public class StatusEditHistoryFragment extends StatusListFragment{ action=getString(R.string.edit_multiple_changed); } } - items.add(0, new ReblogOrReplyLineStatusDisplayItem(s.id, this, action+" · "+date, Collections.emptyList(), 0, null, null)); + String sep = getString(R.string.sk_separator); + items.add(0, new ReblogOrReplyLineStatusDisplayItem(s.id, this, action+" "+sep+" "+date, Collections.emptyList(), 0, null, null)); } return items; } diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/StatusListFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/StatusListFragment.java index a9ce3120e..7293a2fac 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/StatusListFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/StatusListFragment.java @@ -6,12 +6,14 @@ import android.os.Bundle; import com.squareup.otto.Subscribe; import org.joinmastodon.android.E; +import org.joinmastodon.android.GlobalUserPreferences; import org.joinmastodon.android.events.PollUpdatedEvent; import org.joinmastodon.android.events.RemoveAccountPostsEvent; import org.joinmastodon.android.events.StatusCountersUpdatedEvent; import org.joinmastodon.android.events.StatusCreatedEvent; import org.joinmastodon.android.events.StatusDeletedEvent; import org.joinmastodon.android.events.StatusUpdatedEvent; +import org.joinmastodon.android.model.Filter; import org.joinmastodon.android.model.Status; import org.joinmastodon.android.ui.displayitems.ExtendedFooterStatusDisplayItem; import org.joinmastodon.android.ui.displayitems.FooterStatusDisplayItem; @@ -30,7 +32,9 @@ public abstract class StatusListFragment extends BaseStatusListFragment{ protected EventListener eventListener=new EventListener(); protected List buildDisplayItems(Status s){ - return StatusDisplayItem.buildItems(this, s, accountID, s, knownAccounts, false, true, null); + boolean addFooter = !GlobalUserPreferences.spectatorMode || + (this instanceof ThreadFragment t && s.id.equals(t.mainStatus.id)); + return StatusDisplayItem.buildItems(this, s, accountID, s, knownAccounts, false, addFooter, null, Filter.FilterContext.HOME); } @Override @@ -56,6 +60,7 @@ public abstract class StatusListFragment extends BaseStatusListFragment{ Status status=getContentStatusByID(id); if(status==null) return; + status.filterRevealed = true; Bundle args=new Bundle(); args.putString("account", accountID); args.putParcelable("status", Parcels.wrap(status)); 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 b4de4d184..03cadb3b0 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/ThreadFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/ThreadFragment.java @@ -26,7 +26,7 @@ import java.util.stream.Collectors; import me.grishka.appkit.api.SimpleCallback; public class ThreadFragment extends StatusListFragment{ - private Status mainStatus; + protected Status mainStatus; @Override public void onCreate(Bundle savedInstanceState){ @@ -61,8 +61,7 @@ public class ThreadFragment extends StatusListFragment{ .setCallback(new SimpleCallback<>(this){ @Override public void onSuccess(StatusContext result){ - if(getActivity()==null) - return; + if (getActivity() == null) return; if(refreshing){ data.clear(); displayItems.clear(); @@ -126,4 +125,14 @@ public class ThreadFragment extends StatusListFragment{ public boolean isItemEnabled(String id){ return !id.equals(mainStatus.id); } + + @Override + public boolean wantsLightStatusBar(){ + return !UiUtils.isDarkTheme(); + } + + @Override + public boolean wantsLightNavigationBar(){ + return !UiUtils.isDarkTheme(); + } } 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 b8dd2017b..c70e83129 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 @@ -101,6 +101,7 @@ public abstract class BaseAccountListFragment extends BaseRecyclerFragment(this){ @Override public void onSuccess(List result){ + if (getActivity() == null) return; onDataLoaded(result.stream().map(fs->new AccountWrapper(fs.account)).collect(Collectors.toList()), false); loadRelationships(); } @@ -108,6 +109,7 @@ public class DiscoverAccountsFragment extends BaseRecyclerFragment result){ relationshipsRequest=null; relationships=result.stream().collect(Collectors.toMap(rel->rel.id, Function.identity())); + if (getActivity() == null) return; if(list==null) return; for(int i=0;i implements imageRequests=result.stream() .map(card->TextUtils.isEmpty(card.image) ? null : new UrlImageLoaderRequest(card.image, V.dp(150), V.dp(150))) .collect(Collectors.toList()); + if (getActivity() == null) return; onDataLoaded(result, false); } }) 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 34fdcc968..0d73a4e9f 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 @@ -6,10 +6,13 @@ import android.view.View; import org.joinmastodon.android.api.requests.trends.GetTrendingStatuses; import org.joinmastodon.android.fragments.IsOnTop; import org.joinmastodon.android.fragments.StatusListFragment; +import org.joinmastodon.android.model.Filter; import org.joinmastodon.android.model.Status; import org.joinmastodon.android.ui.utils.DiscoverInfoBannerHelper; +import org.joinmastodon.android.utils.StatusFilterPredicate; import java.util.List; +import java.util.stream.Collectors; import me.grishka.appkit.api.SimpleCallback; @@ -22,6 +25,8 @@ public class DiscoverPostsFragment extends StatusListFragment implements IsOnTop .setCallback(new SimpleCallback<>(this){ @Override public void onSuccess(List result){ + if (getActivity() == null) return; + result=result.stream().filter(new StatusFilterPredicate(accountID, Filter.FilterContext.PUBLIC)).collect(Collectors.toList()); onDataLoaded(result, !result.isEmpty()); } }).exec(accountID); 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 59cae5f96..7cefb5ab6 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 @@ -5,7 +5,6 @@ import android.view.View; import org.joinmastodon.android.R; import org.joinmastodon.android.api.requests.timelines.GetPublicTimeline; -import org.joinmastodon.android.fragments.FabStatusListFragment; import org.joinmastodon.android.fragments.StatusListFragment; import org.joinmastodon.android.model.Filter; import org.joinmastodon.android.model.Status; @@ -17,10 +16,16 @@ import java.util.stream.Collectors; import me.grishka.appkit.api.SimpleCallback; -public class FederatedTimelineFragment extends FabStatusListFragment { +public class FederatedTimelineFragment extends StatusListFragment { private DiscoverInfoBannerHelper bannerHelper=new DiscoverInfoBannerHelper(DiscoverInfoBannerHelper.BannerType.FEDERATED_TIMELINE); private String maxID; + @Override + protected boolean withComposeButton() { + return true; + } + + @Override protected void doLoadData(int offset, int count){ currentRequest=new GetPublicTimeline(false, false, refreshing ? null : maxID, count) @@ -29,7 +34,9 @@ public class FederatedTimelineFragment extends FabStatusListFragment { public void onSuccess(List result){ if(!result.isEmpty()) maxID=result.get(result.size()-1).id; - onDataLoaded(result.stream().filter(new StatusFilterPredicate(accountID, Filter.FilterContext.PUBLIC)).collect(Collectors.toList()), !result.isEmpty()); + if (getActivity() == null) return; + result=result.stream().filter(new StatusFilterPredicate(accountID, Filter.FilterContext.PUBLIC)).collect(Collectors.toList()); + onDataLoaded(result, !result.isEmpty()); } }) .exec(accountID); 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 bd1443124..dc2dff4c9 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 @@ -3,9 +3,7 @@ package org.joinmastodon.android.fragments.discover; import android.os.Bundle; import android.view.View; -import org.joinmastodon.android.R; import org.joinmastodon.android.api.requests.timelines.GetPublicTimeline; -import org.joinmastodon.android.fragments.FabStatusListFragment; import org.joinmastodon.android.fragments.StatusListFragment; import org.joinmastodon.android.model.Filter; import org.joinmastodon.android.model.Status; @@ -17,10 +15,16 @@ import java.util.stream.Collectors; import me.grishka.appkit.api.SimpleCallback; -public class LocalTimelineFragment extends FabStatusListFragment { +public class LocalTimelineFragment extends StatusListFragment { private DiscoverInfoBannerHelper bannerHelper=new DiscoverInfoBannerHelper(DiscoverInfoBannerHelper.BannerType.LOCAL_TIMELINE); private String maxID; + @Override + protected boolean withComposeButton() { + return true; + } + + @Override protected void doLoadData(int offset, int count){ currentRequest=new GetPublicTimeline(true, false, refreshing ? null : maxID, count) @@ -29,7 +33,9 @@ public class LocalTimelineFragment extends FabStatusListFragment { public void onSuccess(List result){ if(!result.isEmpty()) maxID=result.get(result.size()-1).id; - onDataLoaded(result.stream().filter(new StatusFilterPredicate(accountID, Filter.FilterContext.PUBLIC)).collect(Collectors.toList()), !result.isEmpty()); + if (getActivity() == null) return; + result=result.stream().filter(new StatusFilterPredicate(accountID, Filter.FilterContext.PUBLIC)).collect(Collectors.toList()); + onDataLoaded(result, !result.isEmpty()); } }) .exec(accountID); 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 cf826d30f..50a8e71b5 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 @@ -62,7 +62,7 @@ public class SearchFragment extends BaseStatusListFragment impleme if(Build.VERSION.SDK_INT>=Build.VERSION_CODES.N) setRetainInstance(true); loadData(); - setEmptyText(R.string.sk_recent_searches_placeholder); + resetEmptyText(); } @Override @@ -71,6 +71,10 @@ public class SearchFragment extends BaseStatusListFragment impleme imm=activity.getSystemService(InputMethodManager.class); } + private void resetEmptyText() { + setEmptyText(R.string.sk_recent_searches_placeholder); + } + @Override protected List buildDisplayItems(SearchResult s){ return switch(s.type){ @@ -120,6 +124,8 @@ public class SearchFragment extends BaseStatusListFragment impleme @Override protected void doLoadData(int offset, int count){ + if (getActivity() == null) return; + resetEmptyText(); if(isInRecentMode()){ AccountSessionManager.getInstance().getAccount(accountID).getCacheController().getRecentSearches(sr->{ if(getActivity()==null) @@ -129,11 +135,13 @@ public class SearchFragment extends BaseStatusListFragment impleme onDataLoaded(sr, false); }); }else{ + setEmptyText(R.string.sk_searching); progressVisibilityListener.onProgressVisibilityChanged(true); currentRequest=new GetSearchResults(currentQuery, null, true) .setCallback(new Callback<>(){ @Override public void onSuccess(SearchResults result){ + setEmptyText(R.string.sk_no_results); ArrayList results=new ArrayList<>(); if(result.accounts!=null){ for(Account acc:result.accounts) @@ -149,11 +157,13 @@ public class SearchFragment extends BaseStatusListFragment impleme } prevDisplayItems=new ArrayList<>(displayItems); unfilteredResults=results; + if (getActivity() == null) return; onDataLoaded(filterSearchResults(results), false); } @Override public void onError(ErrorResponse error){ + resetEmptyText(); currentRequest=null; Activity a=getActivity(); if(a==null) diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/discover/TrendingHashtagsFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/discover/TrendingHashtagsFragment.java index 26f345693..01c98c615 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/discover/TrendingHashtagsFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/discover/TrendingHashtagsFragment.java @@ -44,6 +44,7 @@ public class TrendingHashtagsFragment extends BaseRecyclerFragment impl .setCallback(new SimpleCallback<>(this){ @Override public void onSuccess(List result){ + if (getActivity() == null) return; onDataLoaded(result, false); } }) 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 dd86fdae6..129e95fb4 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 @@ -193,30 +193,24 @@ public class AccountActivationFragment extends ToolbarFragment{ mgr.removeAccount(accountID); mgr.addAccount(mgr.getInstanceInfo(session.domain), session.token, result, session.app, null); String newID=mgr.getLastActiveAccountID(); - Bundle args=new Bundle(); - args.putString("account", newID); - if(session.self.avatar!=null || session.self.displayName!=null){ - File avaFile=session.self.avatar!=null ? new File(session.self.avatar) : null; - new UpdateAccountCredentials(session.self.displayName, "", avaFile, null, Collections.emptyList()) + accountID=newID; + if((session.self.avatar!=null || session.self.displayName!=null) && !getArguments().getBoolean("debug")){ + new UpdateAccountCredentials(session.self.displayName, "", (File)null, null, Collections.emptyList()) .setCallback(new Callback<>(){ @Override public void onSuccess(Account result){ - if(avaFile!=null) - avaFile.delete(); mgr.updateAccountInfo(newID, result); - Nav.goClearingStack(getActivity(), HomeFragment.class, args); + proceed(); } @Override public void onError(ErrorResponse error){ - if(avaFile!=null) - avaFile.delete(); - Nav.goClearingStack(getActivity(), HomeFragment.class, args); + proceed(); } }) .exec(newID); }else{ - Nav.goClearingStack(getActivity(), HomeFragment.class, args); + proceed(); } } @@ -249,4 +243,11 @@ public class AccountActivationFragment extends ToolbarFragment{ super.onDestroyView(); resendBtn.removeCallbacks(resendTimer); } + + private void proceed(){ + Bundle args=new Bundle(); + args.putString("account", accountID); +// Nav.goClearingStack(getActivity(), HomeFragment.class, args); + Nav.goClearingStack(getActivity(), OnboardingProfileSetupFragment.class, args); + } } 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 3cf0767c9..5f0404ac2 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 @@ -5,6 +5,7 @@ import android.graphics.Paint; import android.graphics.Rect; import android.os.Build; import android.os.Bundle; +import android.text.TextUtils; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; @@ -19,6 +20,7 @@ import org.joinmastodon.android.model.Instance; import org.joinmastodon.android.ui.DividerItemDecoration; import org.joinmastodon.android.ui.OutlineProviders; import org.joinmastodon.android.ui.utils.UiUtils; +import org.joinmastodon.android.utils.ElevationOnScrollListener; import org.jsoup.Jsoup; import org.jsoup.nodes.Document; import org.parceler.Parcels; @@ -42,6 +44,7 @@ import me.grishka.appkit.utils.BindableViewHolder; import me.grishka.appkit.utils.MergeRecyclerAdapter; import me.grishka.appkit.utils.SingleViewRecyclerAdapter; import me.grishka.appkit.utils.V; +import me.grishka.appkit.views.FragmentRootLinearLayout; import me.grishka.appkit.views.UsableRecyclerView; import okhttp3.Call; import okhttp3.Callback; @@ -58,6 +61,9 @@ public class GoogleMadeMeAddThisFragment extends ToolbarFragment{ private ArrayList items=new ArrayList<>(); private Call currentRequest; private ItemsAdapter itemsAdapter; + private ElevationOnScrollListener onScrollListener; + + private static final int SIGNUP_REQUEST=722; @Override public void onCreate(Bundle savedInstanceState){ @@ -72,7 +78,7 @@ public class GoogleMadeMeAddThisFragment extends ToolbarFragment{ setNavigationBarColor(UiUtils.getThemeColor(activity, R.attr.colorWindowBackground)); instance=Parcels.unwrap(getArguments().getParcelable("instance")); - items.add(new Item("Mastodon for Android Privacy Policy", "joinmastodon.org", "https://joinmastodon.org/android/privacy", "https://joinmastodon.org/favicon-32x32.png")); + items.add(new Item("Mastodon for Android Privacy Policy", getString(R.string.privacy_policy_explanation), "joinmastodon.org", "https://joinmastodon.org/android/privacy", "https://joinmastodon.org/favicon-32x32.png")); loadServerPrivacyPolicy(); } @@ -93,18 +99,24 @@ public class GoogleMadeMeAddThisFragment extends ToolbarFragment{ list.setLayoutManager(new LinearLayoutManager(getActivity())); View headerView=inflater.inflate(R.layout.item_list_header_simple, list, false); TextView text=headerView.findViewById(R.id.text); - text.setText(R.string.privacy_policy_subtitle); + text.setText(getString(R.string.privacy_policy_subtitle, instance.uri)); adapter=new MergeRecyclerAdapter(); adapter.addAdapter(new SingleViewRecyclerAdapter(headerView)); adapter.addAdapter(itemsAdapter=new ItemsAdapter()); list.setAdapter(adapter); - list.addItemDecoration(new DividerItemDecoration(getActivity(), R.attr.colorM3SurfaceVariant, 1, 56, 0, DividerItemDecoration.NOT_FIRST)); btn=view.findViewById(R.id.btn_next); btn.setOnClickListener(v->onButtonClick()); buttonBar=view.findViewById(R.id.button_bar); + Button backBtn=view.findViewById(R.id.btn_back); + backBtn.setText(getString(R.string.server_policy_disagree, instance.uri)); + backBtn.setOnClickListener(v->{ + setResult(false, null); + Nav.finish(this); + }); + return view; } @@ -113,19 +125,32 @@ public class GoogleMadeMeAddThisFragment extends ToolbarFragment{ super.onViewCreated(view, savedInstanceState); setStatusBarColor(UiUtils.getThemeColor(getActivity(), R.attr.colorM3Background)); view.setBackgroundColor(UiUtils.getThemeColor(getActivity(), R.attr.colorM3Background)); + list.addOnScrollListener(onScrollListener=new ElevationOnScrollListener((FragmentRootLinearLayout) view, buttonBar, getToolbar())); } @Override protected void onUpdateToolbar(){ super.onUpdateToolbar(); - getToolbar().setBackground(null); + getToolbar().setBackgroundResource(R.drawable.bg_onboarding_panel); getToolbar().setElevation(0); + if(onScrollListener!=null){ + onScrollListener.setViews(buttonBar, getToolbar()); + } } protected void onButtonClick(){ Bundle args=new Bundle(); args.putParcelable("instance", Parcels.wrap(instance)); - Nav.go(getActivity(), SignupFragment.class, args); + Nav.goForResult(getActivity(), SignupFragment.class, args, SIGNUP_REQUEST, this); + } + + @Override + public void onFragmentResult(int reqCode, boolean success, Bundle result){ + super.onFragmentResult(reqCode, success, result); + if(reqCode==SIGNUP_REQUEST && !success){ + setResult(false, null); + Nav.finish(this); + } } @Override @@ -158,7 +183,7 @@ public class GoogleMadeMeAddThisFragment extends ToolbarFragment{ if(!response.isSuccessful()) return; Document doc=Jsoup.parse(Objects.requireNonNull(body).byteStream(), Objects.requireNonNull(body.contentType()).charset(StandardCharsets.UTF_8).name(), req.url().toString()); - final Item item=new Item(doc.title(), instance.uri, req.url().toString(), "https://"+instance.uri+"/favicon.ico"); + final Item item=new Item(doc.title(), null, instance.uri, req.url().toString(), "https://"+instance.uri+"/favicon.ico"); Activity activity=getActivity(); if(activity!=null){ activity.runOnUiThread(()->{ @@ -192,16 +217,23 @@ public class GoogleMadeMeAddThisFragment extends ToolbarFragment{ private class ItemViewHolder extends BindableViewHolder implements UsableRecyclerView.Clickable{ private final TextView title; + private final TextView subtitle; public ItemViewHolder(){ super(getActivity(), R.layout.item_privacy_policy_link, list); title=findViewById(R.id.title); - title.setPaintFlags(title.getPaintFlags() | Paint.UNDERLINE_TEXT_FLAG); + subtitle=findViewById(R.id.subtitle); } @Override public void onBind(Item item){ title.setText(item.title); + if(TextUtils.isEmpty(item.subtitle)){ + subtitle.setVisibility(View.GONE); + }else{ + subtitle.setVisibility(View.VISIBLE); + subtitle.setText(item.subtitle); + } } @Override @@ -211,10 +243,11 @@ public class GoogleMadeMeAddThisFragment extends ToolbarFragment{ } private static class Item{ - public String title, domain, url, faviconUrl; + public String title, subtitle, domain, url, faviconUrl; - public Item(String title, String domain, String url, String faviconUrl){ + public Item(String title, String subtitle, String domain, String url, String faviconUrl){ this.title=title; + this.subtitle=subtitle; this.domain=domain; this.url=url; this.faviconUrl=faviconUrl; 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 2d17d6272..7c463f191 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 @@ -92,7 +92,7 @@ abstract class InstanceCatalogFragment extends BaseRecyclerFragment 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()){ - return; - } - chosenInstance=instances.get(new Random().nextInt(instances.size())); - } - super.onNextClick(v); - } - @Override protected void proceedWithAuthOrSignup(Instance instance){ getActivity().getSystemService(InputMethodManager.class).hideSoftInputFromWindow(contentView.getWindowToken(), 0); @@ -428,6 +365,19 @@ public class InstanceCatalogSignupFragment extends InstanceCatalogFragment imple 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()){ + return; + } + chosenInstance=instances.get(new Random().nextInt(instances.size())); + onNextClick(v); + } + // private String getEmojiForCategory(String category){ // return switch(category){ // case "all" -> "💬"; @@ -577,7 +527,16 @@ public class InstanceCatalogSignupFragment extends InstanceCatalogFragment imple updateFilteredList(); } - private class InstancesAdapter extends UsableRecyclerView.Adapter implements ImageLoaderRecyclerAdapter{ + @Override + protected void onShown(){ + super.onShown(); + if(!searchQueryMode){ + // Prevent search view automatically getting focused when the user returns to this fragment + focusThing.requestFocus(); + } + } + + private class InstancesAdapter extends UsableRecyclerView.Adapter{ public InstancesAdapter(){ super(imgLoader); } @@ -603,22 +562,11 @@ public class InstanceCatalogSignupFragment extends InstanceCatalogFragment imple public int getItemViewType(int position){ return -1; } - - @Override - public int getImageCountForItem(int position){ - return filteredData.get(position).thumbnailRequest!=null ? 1 : 0; - } - - @Override - public ImageLoaderRequest getImageRequest(int position, int image){ - return filteredData.get(position).thumbnailRequest; - } } - private class InstanceViewHolder extends BindableViewHolder implements UsableRecyclerView.DisableableClickable, ImageLoaderViewHolder{ + private class InstanceViewHolder extends BindableViewHolder implements UsableRecyclerView.DisableableClickable{ private final TextView title, description; private final RadioButton radioButton; - private final ImageView thumbnail; private boolean enabled; public InstanceViewHolder(){ @@ -626,15 +574,12 @@ public class InstanceCatalogSignupFragment extends InstanceCatalogFragment imple title=findViewById(R.id.title); description=findViewById(R.id.description); radioButton=findViewById(R.id.radiobtn); - thumbnail=findViewById(R.id.image); } @Override public void onBind(CatalogInstance item){ title.setText(item.normalizedDomain); radioButton.setChecked(chosenInstance==item); - if(item.thumbnailRequest==null) - thumbnail.setImageDrawable(null); Instance realInstance=instancesCache.get(item.normalizedDomain); float alpha; if(realInstance!=null && !realInstance.registrations){ @@ -649,7 +594,6 @@ public class InstanceCatalogSignupFragment extends InstanceCatalogFragment imple title.setAlpha(alpha); description.setAlpha(alpha); radioButton.setAlpha(alpha); - thumbnail.setAlpha(alpha); } @Override @@ -672,6 +616,9 @@ public class InstanceCatalogSignupFragment extends InstanceCatalogFragment imple adapter.notifyItemChanged(idx); } } + if(!nextButton.isEnabled()){ + nextButton.setEnabled(true); + } radioButton.setChecked(true); if(chosenInstance==null) nextButton.setEnabled(true); @@ -679,16 +626,6 @@ public class InstanceCatalogSignupFragment extends InstanceCatalogFragment imple loadInstanceInfo(chosenInstance.domain, false); } - @Override - public void setImage(int index, Drawable image){ - thumbnail.setImageDrawable(image); - } - - @Override - public void clearImage(int index){ - setImage(index, null); - } - @Override public boolean isEnabled(){ return enabled; @@ -710,4 +647,5 @@ public class InstanceCatalogSignupFragment extends InstanceCatalogFragment imple return (this==GENERAL)==isGeneral; } } + } 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 85563bfce..6dd47f224 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 @@ -17,6 +17,7 @@ import org.joinmastodon.android.model.Instance; import org.joinmastodon.android.ui.DividerItemDecoration; import org.joinmastodon.android.ui.text.HtmlParser; import org.joinmastodon.android.ui.utils.UiUtils; +import org.joinmastodon.android.utils.ElevationOnScrollListener; import org.parceler.Parcels; import androidx.annotation.NonNull; @@ -28,6 +29,7 @@ import me.grishka.appkit.utils.BindableViewHolder; import me.grishka.appkit.utils.MergeRecyclerAdapter; import me.grishka.appkit.utils.SingleViewRecyclerAdapter; import me.grishka.appkit.utils.V; +import me.grishka.appkit.views.FragmentRootLinearLayout; import me.grishka.appkit.views.UsableRecyclerView; public class InstanceRulesFragment extends ToolbarFragment{ @@ -36,6 +38,9 @@ public class InstanceRulesFragment extends ToolbarFragment{ private Button btn; private View buttonBar; private Instance instance; + private ElevationOnScrollListener onScrollListener; + + private static final int RULES_REQUEST=376; @Override public void onCreate(Bundle savedInstanceState){ @@ -71,6 +76,8 @@ public class InstanceRulesFragment extends ToolbarFragment{ btn.setOnClickListener(v->onButtonClick()); buttonBar=view.findViewById(R.id.button_bar); + view.findViewById(R.id.btn_back).setOnClickListener(v->Nav.finish(this)); + return view; } @@ -79,19 +86,31 @@ public class InstanceRulesFragment extends ToolbarFragment{ super.onViewCreated(view, savedInstanceState); // setStatusBarColor(UiUtils.getThemeColor(getActivity(), R.attr.colorM3Background)); // view.setBackgroundColor(UiUtils.getThemeColor(getActivity(), R.attr.colorM3Background)); + list.addOnScrollListener(onScrollListener=new ElevationOnScrollListener((FragmentRootLinearLayout) view, buttonBar, getToolbar())); } @Override protected void onUpdateToolbar(){ super.onUpdateToolbar(); - getToolbar().setBackground(null); + getToolbar().setBackgroundResource(R.drawable.bg_onboarding_panel); getToolbar().setElevation(0); + if(onScrollListener!=null){ + onScrollListener.setViews(buttonBar, getToolbar()); + } } protected void onButtonClick(){ Bundle args=new Bundle(); args.putParcelable("instance", Parcels.wrap(instance)); - Nav.go(getActivity(), GoogleMadeMeAddThisFragment.class, args); + Nav.goForResult(getActivity(), GoogleMadeMeAddThisFragment.class, args, RULES_REQUEST, this); + } + + @Override + public void onFragmentResult(int reqCode, boolean success, Bundle result){ + super.onFragmentResult(reqCode, success, result); + if(reqCode==RULES_REQUEST && !success){ + Nav.finish(this); + } } @Override 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 new file mode 100644 index 000000000..f1ca5c4f7 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/onboarding/OnboardingFollowSuggestionsFragment.java @@ -0,0 +1,350 @@ +package org.joinmastodon.android.fragments.onboarding; + +import android.app.ProgressDialog; +import android.graphics.drawable.Animatable; +import android.graphics.drawable.Drawable; +import android.os.Build; +import android.os.Bundle; +import android.text.TextUtils; +import android.view.View; +import android.view.ViewGroup; +import android.view.WindowInsets; +import android.widget.Button; +import android.widget.FrameLayout; +import android.widget.ImageView; +import android.widget.ProgressBar; +import android.widget.TextView; + +import org.joinmastodon.android.R; +import org.joinmastodon.android.api.requests.accounts.GetAccountRelationships; +import org.joinmastodon.android.api.requests.accounts.GetFollowSuggestions; +import org.joinmastodon.android.api.requests.accounts.SetAccountFollowed; +import org.joinmastodon.android.fragments.HomeFragment; +import org.joinmastodon.android.fragments.ProfileFragment; +import org.joinmastodon.android.model.FollowSuggestion; +import org.joinmastodon.android.model.ParsedAccount; +import org.joinmastodon.android.model.Relationship; +import org.joinmastodon.android.ui.OutlineProviders; +import org.joinmastodon.android.ui.utils.UiUtils; +import org.joinmastodon.android.ui.views.ProgressBarButton; +import org.joinmastodon.android.utils.ElevationOnScrollListener; +import org.parceler.Parcels; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; + +import androidx.annotation.NonNull; +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.fragments.BaseRecyclerFragment; +import me.grishka.appkit.imageloader.ImageLoaderRecyclerAdapter; +import me.grishka.appkit.imageloader.ImageLoaderViewHolder; +import me.grishka.appkit.imageloader.requests.ImageLoaderRequest; +import me.grishka.appkit.utils.BindableViewHolder; +import me.grishka.appkit.utils.V; +import me.grishka.appkit.views.FragmentRootLinearLayout; +import me.grishka.appkit.views.UsableRecyclerView; + +public class OnboardingFollowSuggestionsFragment extends BaseRecyclerFragment{ + private String accountID; + private Map relationships=Collections.emptyMap(); + private GetAccountRelationships relationshipsRequest; + private View buttonBar; + private ElevationOnScrollListener onScrollListener; + private int numRunningFollowRequests=0; + + public OnboardingFollowSuggestionsFragment(){ + super(R.layout.fragment_onboarding_follow_suggestions, 40); + } + + @Override + public void onCreate(Bundle savedInstanceState){ + super.onCreate(savedInstanceState); + setRetainInstance(true); + setTitle(R.string.popular_on_mastodon); + accountID=getArguments().getString("account"); + loadData(); + } + + @Override + public void onViewCreated(View view, Bundle savedInstanceState){ + super.onViewCreated(view, savedInstanceState); + buttonBar=view.findViewById(R.id.button_bar); + setStatusBarColor(UiUtils.getThemeColor(getActivity(), R.attr.colorM3Background)); + view.setBackgroundColor(UiUtils.getThemeColor(getActivity(), R.attr.colorM3Background)); + 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())); + } + + @Override + protected void onUpdateToolbar(){ + super.onUpdateToolbar(); + getToolbar().setBackgroundResource(R.drawable.bg_onboarding_panel); + getToolbar().setElevation(0); + if(onScrollListener!=null){ + onScrollListener.setViews(buttonBar, getToolbar()); + } + } + + @Override + protected void doLoadData(int offset, int count){ + new GetFollowSuggestions(40) + .setCallback(new SimpleCallback<>(this){ + @Override + public void onSuccess(List result){ + onDataLoaded(result.stream().map(fs->new ParsedAccount(fs.account, accountID)).collect(Collectors.toList()), false); + loadRelationships(); + } + }) + .exec(accountID); + } + + private void loadRelationships(){ + relationships=Collections.emptyMap(); + relationshipsRequest=new GetAccountRelationships(data.stream().map(fs->fs.account.id).collect(Collectors.toList())); + relationshipsRequest.setCallback(new Callback<>(){ + @Override + public void onSuccess(List result){ + relationshipsRequest=null; + relationships=result.stream().collect(Collectors.toMap(rel->rel.id, Function.identity())); + if(list==null) + return; + for(int i=0;i=27){ + int inset=insets.getSystemWindowInsetBottom(); + buttonBar.setPadding(0, 0, 0, inset>0 ? Math.max(inset, V.dp(36)) : 0); + super.onApplyWindowInsets(insets.replaceSystemWindowInsets(insets.getSystemWindowInsetLeft(), insets.getSystemWindowInsetTop(), insets.getSystemWindowInsetRight(), 0)); + }else{ + super.onApplyWindowInsets(insets.replaceSystemWindowInsets(insets.getSystemWindowInsetLeft(), insets.getSystemWindowInsetTop(), insets.getSystemWindowInsetRight(), insets.getSystemWindowInsetBottom())); + } + } + + @Override + protected RecyclerView.Adapter getAdapter(){ + return new SuggestionsAdapter(); + } + + private void onFollowAllClick(View v){ + if(!loaded || relationships.isEmpty()) + return; + if(data.isEmpty()){ + proceed(); + return; + } + ArrayList accountIdsToFollow=new ArrayList<>(); + for(ParsedAccount acc:data){ + Relationship rel=relationships.get(acc.account.id); + if(rel==null) + continue; + if(rel.canFollow()) + accountIdsToFollow.add(acc.account.id); + } + + final ProgressDialog progress=new ProgressDialog(getActivity()); + progress.setIndeterminate(false); + progress.setProgressStyle(ProgressDialog.STYLE_HORIZONTAL); + progress.setMax(accountIdsToFollow.size()); + progress.setCancelable(false); + progress.setMessage(getString(R.string.sending_follows)); + progress.show(); + + for(int i=0;i accountIdsToFollow, ProgressDialog progress){ + if(accountIdsToFollow.isEmpty()){ + if(numRunningFollowRequests==0){ + progress.dismiss(); + proceed(); + } + return; + } + numRunningFollowRequests++; + String id=accountIdsToFollow.remove(0); + new SetAccountFollowed(id, true, true) + .setCallback(new Callback<>(){ + @Override + public void onSuccess(Relationship result){ + numRunningFollowRequests--; + progress.setProgress(progress.getMax()-accountIdsToFollow.size()-numRunningFollowRequests); + followNextAccount(accountIdsToFollow, progress); + } + + @Override + public void onError(ErrorResponse error){ + numRunningFollowRequests--; + progress.setProgress(progress.getMax()-accountIdsToFollow.size()-numRunningFollowRequests); + followNextAccount(accountIdsToFollow, progress); + } + }) + .exec(accountID); + } + + private void proceed(){ + Bundle args=new Bundle(); + args.putString("account", accountID); + Nav.go(getActivity(), HomeFragment.class, args); + getActivity().getWindow().getDecorView().postDelayed(()->Nav.finish(this), 500); + } + + @Override + protected boolean canGoBack(){ + return true; + } + + @Override + public void onToolbarNavigationClick(){ + Bundle args=new Bundle(); + args.putString("account", accountID); + Nav.goClearingStack(getActivity(), HomeFragment.class, args); + } + + private class SuggestionsAdapter extends UsableRecyclerView.Adapter implements ImageLoaderRecyclerAdapter{ + + public SuggestionsAdapter(){ + super(imgLoader); + } + + @NonNull + @Override + public SuggestionViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType){ + return new SuggestionViewHolder(); + } + + @Override + public int getItemCount(){ + return data.size(); + } + + @Override + public void onBindViewHolder(SuggestionViewHolder holder, int position){ + holder.bind(data.get(position)); + super.onBindViewHolder(holder, position); + } + + @Override + public int getImageCountForItem(int position){ + return data.get(position).emojiHelper.getImageCount()+1; + } + + @Override + public ImageLoaderRequest getImageRequest(int position, int image){ + ParsedAccount account=data.get(position); + if(image==0) + return account.avatarRequest; + return account.emojiHelper.getImageRequest(image-1); + } + } + + private class SuggestionViewHolder extends BindableViewHolder implements ImageLoaderViewHolder, UsableRecyclerView.Clickable{ + private final TextView name, username, bio; + private final ImageView avatar; + private final ProgressBarButton actionButton; + private final ProgressBar actionProgress; + private final View actionWrap; + + private Relationship relationship; + + public SuggestionViewHolder(){ + super(getActivity(), R.layout.item_user_row_m3, list); + name=findViewById(R.id.name); + username=findViewById(R.id.username); + bio=findViewById(R.id.bio); + avatar=findViewById(R.id.avatar); + actionButton=findViewById(R.id.action_btn); + actionProgress=findViewById(R.id.action_progress); + actionWrap=findViewById(R.id.action_btn_wrap); + + avatar.setOutlineProvider(OutlineProviders.roundedRect(10)); + avatar.setClipToOutline(true); + actionButton.setOnClickListener(UiUtils.rateLimitedClickListener(this::onActionButtonClick)); + } + + @Override + public void onBind(ParsedAccount item){ + name.setText(item.parsedName); + username.setText(item.account.getDisplayUsername()); + if(TextUtils.isEmpty(item.parsedBio)){ + bio.setVisibility(View.GONE); + }else{ + bio.setVisibility(View.VISIBLE); + bio.setText(item.parsedBio); + } + + relationship=relationships.get(item.account.id); + if(relationship==null){ + actionWrap.setVisibility(View.GONE); + }else{ + actionWrap.setVisibility(View.VISIBLE); + UiUtils.setRelationshipToActionButtonM3(relationship, actionButton); + } + } + + @Override + public void setImage(int index, Drawable image){ + if(index==0){ + avatar.setImageDrawable(image); + }else{ + item.emojiHelper.setImageDrawable(index-1, image); + name.invalidate(); + bio.invalidate(); + } + if(image instanceof Animatable a && !a.isRunning()) + a.start(); + } + + @Override + public void clearImage(int index){ + setImage(index, null); + } + + @Override + public void onClick(){ + Bundle args=new Bundle(); + args.putString("account", accountID); + args.putParcelable("profileAccount", Parcels.wrap(item.account)); + Nav.go(getActivity(), ProfileFragment.class, args); + } + + private void onActionButtonClick(View v){ + itemView.setHasTransientState(true); + UiUtils.performAccountAction(getActivity(), item.account, accountID, relationship, actionButton, this::setActionProgressVisible, rel->{ + itemView.setHasTransientState(false); + relationships.put(item.account.id, rel); + rebind(); + }); + } + + private void setActionProgressVisible(boolean visible){ + actionButton.setTextVisible(!visible); + actionProgress.setVisibility(visible ? View.VISIBLE : View.GONE); + actionButton.setClickable(!visible); + } + } +} 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 new file mode 100644 index 000000000..a5f1e83c8 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/onboarding/OnboardingProfileSetupFragment.java @@ -0,0 +1,237 @@ +package org.joinmastodon.android.fragments.onboarding; + +import android.app.Activity; +import android.content.Intent; +import android.net.Uri; +import android.os.Build; +import android.os.Bundle; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.view.WindowInsets; +import android.widget.Button; +import android.widget.EditText; +import android.widget.ImageView; +import android.widget.ScrollView; + +import org.joinmastodon.android.R; +import org.joinmastodon.android.api.requests.accounts.UpdateAccountCredentials; +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.ui.OutlineProviders; +import org.joinmastodon.android.ui.utils.UiUtils; +import org.joinmastodon.android.ui.views.ReorderableLinearLayout; +import org.joinmastodon.android.utils.ElevationOnScrollListener; + +import java.util.ArrayList; + +import me.grishka.appkit.Nav; +import me.grishka.appkit.api.Callback; +import me.grishka.appkit.api.ErrorResponse; +import me.grishka.appkit.fragments.ToolbarFragment; +import me.grishka.appkit.imageloader.ViewImageLoader; +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{ + private Button btn; + private View buttonBar; + private String accountID; + private ElevationOnScrollListener onScrollListener; + private ScrollView scroller; + private EditText nameEdit, bioEdit; + private ImageView avaImage, coverImage; + private Button addRow; + private ReorderableLinearLayout profileFieldsLayout; + private Uri avatarUri, coverUri; + + private static final int AVATAR_RESULT=348; + private static final int COVER_RESULT=183; + + @Override + public void onCreate(Bundle savedInstanceState){ + super.onCreate(savedInstanceState); + setRetainInstance(true); + } + + @Override + public void onAttach(Activity activity){ + super.onAttach(activity); + setNavigationBarColor(UiUtils.getThemeColor(activity, R.attr.colorWindowBackground)); + accountID=getArguments().getString("account"); + setTitle(R.string.profile_setup); + } + + @Override + public View onCreateContentView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState){ + View view=inflater.inflate(R.layout.fragment_onboarding_profile_setup, container, false); + + scroller=view.findViewById(R.id.scroller); + nameEdit=view.findViewById(R.id.display_name); + 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()); + buttonBar=view.findViewById(R.id.button_bar); + + avaImage.setOutlineProvider(OutlineProviders.roundedRect(24)); + avaImage.setClipToOutline(true); + + 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)); + + return view; + } + + @Override + public void onViewCreated(View view, Bundle savedInstanceState){ + super.onViewCreated(view, savedInstanceState); + setStatusBarColor(UiUtils.getThemeColor(getActivity(), R.attr.colorM3Background)); + view.setBackgroundColor(UiUtils.getThemeColor(getActivity(), R.attr.colorM3Background)); + scroller.setOnScrollChangeListener(onScrollListener=new ElevationOnScrollListener((FragmentRootLinearLayout) view, buttonBar, getToolbar())); + } + + @Override + protected void onUpdateToolbar(){ + super.onUpdateToolbar(); + getToolbar().setBackgroundResource(R.drawable.bg_onboarding_panel); + getToolbar().setElevation(0); + if(onScrollListener!=null){ + onScrollListener.setViews(buttonBar, getToolbar()); + } + } + + protected void onButtonClick(){ + ArrayList fields=new ArrayList<>(); + for(int i=0;i(){ + @Override + public void onSuccess(Account result){ + AccountSessionManager.getInstance().updateAccountInfo(accountID, result); + Bundle args=new Bundle(); + args.putString("account", accountID); + Nav.go(getActivity(), OnboardingFollowSuggestionsFragment.class, args); + getActivity().getWindow().getDecorView().postDelayed(()->Nav.finish(OnboardingProfileSetupFragment.this), 500); + } + + @Override + public void onError(ErrorResponse error){ + error.showToast(getActivity()); + } + }) + .wrapProgress(getActivity(), R.string.saving, true) + .exec(accountID); + } + + @Override + public void onApplyWindowInsets(WindowInsets insets){ + if(Build.VERSION.SDK_INT>=27){ + int inset=insets.getSystemWindowInsetBottom(); + buttonBar.setPadding(0, 0, 0, inset>0 ? Math.max(inset, V.dp(36)) : 0); + super.onApplyWindowInsets(insets.replaceSystemWindowInsets(insets.getSystemWindowInsetLeft(), insets.getSystemWindowInsetTop(), insets.getSystemWindowInsetRight(), 0)); + }else{ + super.onApplyWindowInsets(insets.replaceSystemWindowInsets(insets.getSystemWindowInsetLeft(), insets.getSystemWindowInsetTop(), insets.getSystemWindowInsetRight(), insets.getSystemWindowInsetBottom())); + } + } + + 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; + }); + 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; @Override public void onCreate(Bundle savedInstanceState){ @@ -145,19 +150,22 @@ public class SignupFragment extends ToolbarFragment{ super.onViewCreated(view, savedInstanceState); setStatusBarColor(UiUtils.getThemeColor(getActivity(), R.attr.colorM3Background)); view.setBackgroundColor(UiUtils.getThemeColor(getActivity(), R.attr.colorM3Background)); + view.findViewById(R.id.scroller).setOnScrollChangeListener(onScrollListener=new ElevationOnScrollListener((FragmentRootLinearLayout) view, buttonBar, getToolbar())); } @Override protected void onUpdateToolbar(){ super.onUpdateToolbar(); - getToolbar().setBackground(null); + getToolbar().setBackgroundResource(R.drawable.bg_onboarding_panel); getToolbar().setElevation(0); + if(onScrollListener!=null){ + onScrollListener.setViews(buttonBar, getToolbar()); + } } private void onButtonClick(){ if(!password.getText().toString().equals(passwordConfirm.getText().toString())){ - passwordConfirm.setError(getString(R.string.signup_passwords_dont_match)); - passwordConfirmWrap.setErrorState(); + passwordConfirmWrap.setErrorState(getString(R.string.signup_passwords_dont_match)); return; } showProgressDialog(); @@ -212,8 +220,22 @@ public class SignupFragment extends ToolbarFragment{ anyFieldsSkipped=true; continue; } - field.setError(fieldErrors.get(fieldName).stream().map(err->err.description).collect(Collectors.joining("\n"))); - getFieldWrapByName(fieldName).setErrorState(); + List errors=Objects.requireNonNull(fieldErrors.get(fieldName)); + if(errors.size()==1){ + getFieldWrapByName(fieldName).setErrorState(getErrorDescription(errors.get(0), fieldName)); + }else{ + SpannableStringBuilder ssb=new SpannableStringBuilder(); + boolean firstErr=true; + for(MastodonDetailedErrorResponse.FieldError err:errors){ + if(firstErr){ + firstErr=false; + }else{ + ssb.append('\n'); + } + ssb.append(getErrorDescription(err, fieldName)); + } + getFieldWrapByName(fieldName).setErrorState(getErrorDescription(errors.get(0), fieldName)); + } errorFields.add(field); if(first){ first=false; @@ -231,6 +253,40 @@ public class SignupFragment extends ToolbarFragment{ .exec(instance.uri, apiToken); } + private CharSequence getErrorDescription(MastodonDetailedErrorResponse.FieldError error, String fieldName){ + return switch(fieldName){ + case "email" -> switch(error.error){ + case "ERR_BLOCKED" -> { + String emailAddr=email.getText().toString(); + String s=getResources().getString(R.string.signup_email_domain_blocked, TextUtils.htmlEncode(instance.uri), TextUtils.htmlEncode(emailAddr.substring(emailAddr.lastIndexOf('@')+1))); + SpannableStringBuilder ssb=new SpannableStringBuilder(); + Jsoup.parseBodyFragment(s).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("", SignupFragment.this::onGoBackLinkClick, LinkSpan.Type.CUSTOM, null), spanStart, ssb.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + ssb.setSpan(new TypefaceSpan("sans-serif-medium"), spanStart, ssb.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + } + } + }); + yield ssb; + } + default -> error.description; + }; + default -> error.description; + }; + } + private EditText getFieldByName(String name){ return switch(name){ case "email" -> email; @@ -323,6 +379,11 @@ public class SignupFragment extends ToolbarFragment{ } } + private void onGoBackLinkClick(LinkSpan span){ + setResult(false, null); + Nav.finish(this); + } + private class ErrorClearingListener implements TextWatcher{ public final EditText editText; diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/report/ReportAddPostsChoiceFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/report/ReportAddPostsChoiceFragment.java index f71552e56..f8c2fc050 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/report/ReportAddPostsChoiceFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/report/ReportAddPostsChoiceFragment.java @@ -89,6 +89,7 @@ public class ReportAddPostsChoiceFragment extends StatusListFragment{ .setCallback(new SimpleCallback<>(this){ @Override public void onSuccess(List result){ + if (getActivity() == null) return; onDataLoaded(result, !result.isEmpty()); } }) diff --git a/mastodon/src/main/java/org/joinmastodon/android/model/Account.java b/mastodon/src/main/java/org/joinmastodon/android/model/Account.java index 18e077c63..9f996d769 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/model/Account.java +++ b/mastodon/src/main/java/org/joinmastodon/android/model/Account.java @@ -133,6 +133,14 @@ public class Account extends BaseModel{ */ public Instant muteExpiresAt; + public List roles; + + @Parcel + public static class Role { + public String name; + /** #rrggbb */ + public String color; + } @Override public void postprocess() throws ObjectValidationException{ diff --git a/mastodon/src/main/java/org/joinmastodon/android/model/Announcement.java b/mastodon/src/main/java/org/joinmastodon/android/model/Announcement.java index 4282da920..1d54b5215 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/model/Announcement.java +++ b/mastodon/src/main/java/org/joinmastodon/android/model/Announcement.java @@ -42,17 +42,9 @@ public class Announcement extends BaseModel implements DisplayItemsParent { } public Status toStatus() { - Status s = new Status(); - s.id = id; - s.mediaAttachments = List.of(); + Status s = Status.ofFake(id, content, publishedAt); s.createdAt = startsAt != null ? startsAt : publishedAt; if (updatedAt != null) s.editedAt = updatedAt; - s.content = s.text = content; - s.spoilerText = ""; - s.visibility = StatusPrivacy.PUBLIC; - s.mentions = List.of(); - s.tags = List.of(); - s.emojis = List.of(); return s; } diff --git a/mastodon/src/main/java/org/joinmastodon/android/model/Attachment.java b/mastodon/src/main/java/org/joinmastodon/android/model/Attachment.java index 212ca9417..999e70733 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/model/Attachment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/model/Attachment.java @@ -47,26 +47,26 @@ public class Attachment extends BaseModel{ public int getWidth(){ if(meta==null) - return 0; + return 1920; if(meta.width>0) return meta.width; if(meta.original!=null && meta.original.width>0) return meta.original.width; if(meta.small!=null && meta.small.width>0) return meta.small.width; - return 0; + return 1920; } public int getHeight(){ if(meta==null) - return 0; + return 1080; if(meta.height>0) return meta.height; if(meta.original!=null && meta.original.height>0) return meta.original.height; if(meta.small!=null && meta.small.height>0) return meta.small.height; - return 0; + return 1080; } public double getDuration(){ diff --git a/mastodon/src/main/java/org/joinmastodon/android/model/Filter.java b/mastodon/src/main/java/org/joinmastodon/android/model/Filter.java index f7b394765..867679498 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/model/Filter.java +++ b/mastodon/src/main/java/org/joinmastodon/android/model/Filter.java @@ -19,6 +19,7 @@ public class Filter extends BaseModel{ public String id; @RequiredField public String phrase; + public String title; public transient EnumSet context=EnumSet.noneOf(FilterContext.class); public Instant expiresAt; public boolean irreversible; @@ -50,6 +51,7 @@ public class Filter extends BaseModel{ else pattern=Pattern.compile(Pattern.quote(phrase), Pattern.CASE_INSENSITIVE); } + if (title == null) title = phrase; return pattern.matcher(text).find(); } @@ -61,6 +63,7 @@ public class Filter extends BaseModel{ public String toString(){ return "Filter{"+ "id='"+id+'\''+ + ", title='"+title+'\''+ ", phrase='"+phrase+'\''+ ", context="+context+ ", expiresAt="+expiresAt+ @@ -77,7 +80,9 @@ public class Filter extends BaseModel{ @SerializedName("public") PUBLIC, @SerializedName("thread") - THREAD + THREAD, + @SerializedName("account") + ACCOUNT } public enum FilterAction{ diff --git a/mastodon/src/main/java/org/joinmastodon/android/model/Instance.java b/mastodon/src/main/java/org/joinmastodon/android/model/Instance.java index 7b546e0ab..b1cc26252 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/model/Instance.java +++ b/mastodon/src/main/java/org/joinmastodon/android/model/Instance.java @@ -45,7 +45,7 @@ public class Instance extends BaseModel{ @RequiredField public String version; /** - * Primary langauges of the website and its staff. + * Primary languages of the website and its staff. */ // @RequiredField public List languages; @@ -84,6 +84,8 @@ public class Instance extends BaseModel{ public V2 v2; + public Pleroma pleroma; + @Override public void postprocess() throws ObjectValidationException{ super.postprocess(); @@ -193,4 +195,9 @@ public class Instance extends BaseModel{ public boolean enabled; } } + + @Parcel + public static class Pleroma extends BaseModel { + // metadata etc + } } diff --git a/mastodon/src/main/java/org/joinmastodon/android/model/ListTimeline.java b/mastodon/src/main/java/org/joinmastodon/android/model/ListTimeline.java index df0d54263..9344756da 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/model/ListTimeline.java +++ b/mastodon/src/main/java/org/joinmastodon/android/model/ListTimeline.java @@ -1,5 +1,7 @@ package org.joinmastodon.android.model; +import androidx.annotation.NonNull; + import com.google.gson.annotations.SerializedName; import org.joinmastodon.android.api.RequiredField; @@ -11,9 +13,9 @@ public class ListTimeline extends BaseModel { public String id; @RequiredField public String title; - @RequiredField public RepliesPolicy repliesPolicy; + @NonNull @Override public String toString() { return "List{" + 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 545028add..b104e86bf 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/model/Notification.java +++ b/mastodon/src/main/java/org/joinmastodon/android/model/Notification.java @@ -18,8 +18,8 @@ public class Notification extends BaseModel implements DisplayItemsParent{ public Instant createdAt; @RequiredField public Account account; - public Status status; + public Report report; @Override public void postprocess() throws ObjectValidationException{ @@ -48,6 +48,19 @@ public class Notification extends BaseModel implements DisplayItemsParent{ @SerializedName("poll") POLL, @SerializedName("status") - STATUS + STATUS, + @SerializedName("update") + UPDATE, + @SerializedName("admin.sign_up") + SIGN_UP, + @SerializedName("admin.report") + REPORT + } + + @Parcel + public static class Report { + public String id; + public String comment; + public Account targetAccount; } } diff --git a/mastodon/src/main/java/org/joinmastodon/android/model/ParsedAccount.java b/mastodon/src/main/java/org/joinmastodon/android/model/ParsedAccount.java new file mode 100644 index 000000000..751b2c0ed --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/model/ParsedAccount.java @@ -0,0 +1,33 @@ +package org.joinmastodon.android.model; + +import android.text.SpannableStringBuilder; + +import org.joinmastodon.android.GlobalUserPreferences; +import org.joinmastodon.android.ui.text.HtmlParser; +import org.joinmastodon.android.ui.utils.CustomEmojiHelper; + +import java.util.Collections; + +import me.grishka.appkit.imageloader.requests.ImageLoaderRequest; +import me.grishka.appkit.imageloader.requests.UrlImageLoaderRequest; +import me.grishka.appkit.utils.V; + +public class ParsedAccount{ + public Account account; + public CharSequence parsedName, parsedBio; + public CustomEmojiHelper emojiHelper; + public ImageLoaderRequest avatarRequest; + + public ParsedAccount(Account account, String accountID){ + this.account=account; + parsedName=HtmlParser.parseCustomEmoji(account.displayName, account.emojis); + parsedBio=HtmlParser.parse(account.note, account.emojis, Collections.emptyList(), Collections.emptyList(), accountID); + + emojiHelper=new CustomEmojiHelper(); + SpannableStringBuilder ssb=new SpannableStringBuilder(parsedName); + ssb.append(parsedBio); + emojiHelper.setText(ssb); + + avatarRequest=new UrlImageLoaderRequest(GlobalUserPreferences.playGifs ? account.avatar : account.avatarStatic, V.dp(40), V.dp(40)); + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/model/PushNotification.java b/mastodon/src/main/java/org/joinmastodon/android/model/PushNotification.java index de5cc3dba..7f81a8e62 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/model/PushNotification.java +++ b/mastodon/src/main/java/org/joinmastodon/android/model/PushNotification.java @@ -45,7 +45,13 @@ public class PushNotification extends BaseModel{ @SerializedName("poll") POLL(R.string.notification_type_poll), @SerializedName("status") - STATUS(R.string.sk_notification_type_status); + STATUS(R.string.sk_notification_type_status), + @SerializedName("update") + UPDATE(R.string.sk_notification_type_update), + @SerializedName("admin.sign_up") + SIGN_UP(R.string.sk_sign_ups), + @SerializedName("admin.report") + REPORT(R.string.sk_new_reports); @StringRes public final int localizedName; diff --git a/mastodon/src/main/java/org/joinmastodon/android/model/PushSubscription.java b/mastodon/src/main/java/org/joinmastodon/android/model/PushSubscription.java index 332c33f06..7d9edd1d4 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/model/PushSubscription.java +++ b/mastodon/src/main/java/org/joinmastodon/android/model/PushSubscription.java @@ -23,6 +23,7 @@ public class PushSubscription extends BaseModel implements Cloneable{ ", endpoint='"+endpoint+'\''+ ", alerts="+alerts+ ", serverKey='"+serverKey+'\''+ + ", policy="+policy+ '}'; } @@ -44,10 +45,19 @@ public class PushSubscription extends BaseModel implements Cloneable{ public boolean mention; public boolean poll; public boolean status; + public boolean update; + + // set to true here because i didn't add any items for those to the settings + // (so i don't have to determine whether the user is an admin to show the items or not, and + // admins can still disable those through the android notifications settings) + @SerializedName("admin.sign_up") + public boolean adminSignUp = true; + @SerializedName("admin.report") + public boolean adminReport = true; public static Alerts ofAll(){ Alerts alerts=new Alerts(); - alerts.follow=alerts.favourite=alerts.reblog=alerts.mention=alerts.poll=alerts.status=true; + alerts.follow=alerts.favourite=alerts.reblog=alerts.mention=alerts.poll=alerts.status=alerts.update=true; return alerts; } @@ -60,6 +70,9 @@ public class PushSubscription extends BaseModel implements Cloneable{ ", mention="+mention+ ", poll="+poll+ ", status="+status+ + ", update="+update+ + ", adminSignUp="+adminSignUp+ + ", adminReport="+adminReport+ '}'; } diff --git a/mastodon/src/main/java/org/joinmastodon/android/model/Relationship.java b/mastodon/src/main/java/org/joinmastodon/android/model/Relationship.java index 837178fef..e5f6ec13e 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/model/Relationship.java +++ b/mastodon/src/main/java/org/joinmastodon/android/model/Relationship.java @@ -18,6 +18,10 @@ public class Relationship extends BaseModel{ public boolean blockedBy; public String note; + public boolean canFollow(){ + return !(following || blocking || blockedBy || domainBlocking); + } + @Override public String toString(){ return "Relationship{"+ diff --git a/mastodon/src/main/java/org/joinmastodon/android/model/ScheduledStatus.java b/mastodon/src/main/java/org/joinmastodon/android/model/ScheduledStatus.java index 02746c771..f4ee206f0 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/model/ScheduledStatus.java +++ b/mastodon/src/main/java/org/joinmastodon/android/model/ScheduledStatus.java @@ -62,19 +62,13 @@ public class ScheduledStatus extends BaseModel implements DisplayItemsParent{ } public Status toStatus() { - Status s = new Status(); - s.id = id; + Status s = Status.ofFake(id, params.text, scheduledAt); s.mediaAttachments = mediaAttachments; - s.createdAt = scheduledAt; s.inReplyToId = params.inReplyToId > 0 ? "" + params.inReplyToId : null; - s.content = s.text = params.text; s.spoilerText = params.spoilerText; s.visibility = params.visibility; s.language = params.language; s.sensitive = params.sensitive; - s.mentions = List.of(); - s.tags = List.of(); - s.emojis = List.of(); if (params.poll != null) s.poll = params.poll.toPoll(); return s; } 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 1ccc858d4..455299287 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/model/Status.java +++ b/mastodon/src/main/java/org/joinmastodon/android/model/Status.java @@ -50,6 +50,7 @@ public class Status extends BaseModel implements DisplayItemsParent{ public Card card; public String language; public String text; + public boolean localOnly; public boolean favourited; public boolean reblogged; @@ -57,7 +58,9 @@ public class Status extends BaseModel implements DisplayItemsParent{ public boolean bookmarked; public boolean pinned; + public transient boolean filterRevealed; public transient boolean spoilerRevealed; + public transient boolean textExpanded, textExpandable; public transient boolean hasGapAfter; private transient String strippedText; @@ -83,6 +86,7 @@ public class Status extends BaseModel implements DisplayItemsParent{ reblog.postprocess(); spoilerRevealed=GlobalUserPreferences.alwaysExpandContentWarnings || !sensitive; + if (visibility.equals(StatusPrivacy.LOCAL)) localOnly = true; } @Override @@ -144,4 +148,19 @@ public class Status extends BaseModel implements DisplayItemsParent{ strippedText=HtmlParser.strip(content); return strippedText; } + + public static Status ofFake(String id, String text, Instant createdAt) { + Status s = new Status(); + s.id = id; + s.mediaAttachments = List.of(); + s.createdAt = createdAt; + s.content = s.text = text; + s.spoilerText = ""; + s.visibility = StatusPrivacy.PUBLIC; + s.mentions = List.of(); + s.tags = List.of(); + s.emojis = List.of(); + s.filtered = List.of(); + return s; + } } diff --git a/mastodon/src/main/java/org/joinmastodon/android/model/StatusPrivacy.java b/mastodon/src/main/java/org/joinmastodon/android/model/StatusPrivacy.java index cb8d6a0e5..dcb0c4e1b 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/model/StatusPrivacy.java +++ b/mastodon/src/main/java/org/joinmastodon/android/model/StatusPrivacy.java @@ -10,7 +10,9 @@ public enum StatusPrivacy{ @SerializedName("private") PRIVATE(2), @SerializedName("direct") - DIRECT(3); + DIRECT(3), + @SerializedName("local") + LOCAL(4); // akkoma private int privacy; diff --git a/mastodon/src/main/java/org/joinmastodon/android/model/TimelineDefinition.java b/mastodon/src/main/java/org/joinmastodon/android/model/TimelineDefinition.java new file mode 100644 index 000000000..fd7c3346a --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/model/TimelineDefinition.java @@ -0,0 +1,252 @@ +package org.joinmastodon.android.model; + +import android.app.Fragment; +import android.content.Context; +import android.os.Bundle; + +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.HashtagTimelineFragment; +import org.joinmastodon.android.fragments.HomeTimelineFragment; +import org.joinmastodon.android.fragments.ListTimelineFragment; +import org.joinmastodon.android.fragments.NotificationsListFragment; +import org.joinmastodon.android.fragments.discover.FederatedTimelineFragment; +import org.joinmastodon.android.fragments.discover.LocalTimelineFragment; + +import java.util.List; +import java.util.Objects; + +public class TimelineDefinition { + private TimelineType type; + private String title; + private @Nullable Icon icon; + + private @Nullable String listId; + private @Nullable String listTitle; + + private @Nullable String hashtagName; + + public static TimelineDefinition ofList(String listId, String listTitle) { + TimelineDefinition def = new TimelineDefinition(TimelineType.LIST); + def.listId = listId; + def.listTitle = listTitle; + return def; + } + + public static TimelineDefinition ofList(ListTimeline list) { + return ofList(list.id, list.title); + } + + public static TimelineDefinition ofHashtag(String hashtag) { + TimelineDefinition def = new TimelineDefinition(TimelineType.HASHTAG); + def.hashtagName = hashtag; + return def; + } + + public static TimelineDefinition ofHashtag(Hashtag hashtag) { + return ofHashtag(hashtag.name); + } + + @SuppressWarnings("unused") + public TimelineDefinition() {} + + public TimelineDefinition(TimelineType type) { + this.type = type; + } + + public String getTitle(Context ctx) { + return title != null ? title : getDefaultTitle(ctx); + } + + public String getCustomTitle() { + return title; + } + + public void setTitle(String title) { + this.title = title == null || title.isBlank() ? null : title; + } + + public String getDefaultTitle(Context ctx) { + return switch (type) { + case HOME -> ctx.getString(R.string.sk_timeline_home); + case LOCAL -> ctx.getString(R.string.sk_timeline_local); + case FEDERATED -> ctx.getString(R.string.sk_timeline_federated); + case POST_NOTIFICATIONS -> ctx.getString(R.string.sk_timeline_posts); + case LIST -> listTitle; + case HASHTAG -> hashtagName; + }; + } + + public Icon getDefaultIcon() { + return switch (type) { + case HOME -> Icon.HOME; + case LOCAL -> Icon.LOCAL; + case FEDERATED -> Icon.FEDERATED; + case POST_NOTIFICATIONS -> Icon.POST_NOTIFICATIONS; + case LIST -> Icon.LIST; + case HASHTAG -> Icon.HASHTAG; + }; + } + + public Fragment getFragment() { + return switch (type) { + case HOME -> new HomeTimelineFragment(); + case LOCAL -> new LocalTimelineFragment(); + case FEDERATED -> new FederatedTimelineFragment(); + case LIST -> new ListTimelineFragment(); + case HASHTAG -> new HashtagTimelineFragment(); + case POST_NOTIFICATIONS -> new NotificationsListFragment(); + }; + } + + @Nullable + public Icon getIcon() { + return icon == null ? getDefaultIcon() : icon; + } + + public void setIcon(@Nullable Icon icon) { + this.icon = icon; + } + + public TimelineType getType() { + return type; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + + TimelineDefinition that = (TimelineDefinition) o; + if (type != that.type) return false; + if (type == TimelineType.LIST) return Objects.equals(listId, that.listId); + if (type == TimelineType.HASHTAG) return Objects.equals(hashtagName.toLowerCase(), that.hashtagName.toLowerCase()); + return true; + } + + @Override + public int hashCode() { + int result = type.ordinal(); + result = 31 * result + (listId != null ? listId.hashCode() : 0); + result = 31 * result + (hashtagName.toLowerCase() != null ? hashtagName.toLowerCase().hashCode() : 0); + return result; + } + + public TimelineDefinition copy() { + TimelineDefinition def = new TimelineDefinition(type); + def.title = title; + def.listId = listId; + def.listTitle = listTitle; + def.hashtagName = hashtagName; + def.icon = icon == null ? null : Icon.values()[icon.ordinal()]; + return def; + } + + public Bundle populateArguments(Bundle args) { + if (type == TimelineType.LIST) { + args.putString("listTitle", title); + args.putString("listID", listId); + } else if (type == TimelineType.HASHTAG) { + args.putString("hashtag", hashtagName); + } + return args; + } + + public enum TimelineType { HOME, LOCAL, FEDERATED, POST_NOTIFICATIONS, LIST, HASHTAG } + + public enum Icon { + HEART(R.drawable.ic_fluent_heart_24_regular, R.string.sk_icon_heart), + STAR(R.drawable.ic_fluent_star_24_regular, R.string.sk_icon_star), + PEOPLE(R.drawable.ic_fluent_people_24_regular, R.string.sk_icon_people), + CITY(R.drawable.ic_fluent_city_24_regular, R.string.sk_icon_city), + IMAGE(R.drawable.ic_fluent_image_24_regular, R.string.sk_icon_image), + NEWS(R.drawable.ic_fluent_news_24_regular, R.string.sk_icon_news), + COLOR_PALETTE(R.drawable.ic_fluent_color_24_regular, R.string.sk_icon_color_palette), + CAT(R.drawable.ic_fluent_animal_cat_24_regular, R.string.sk_icon_cat), + DOG(R.drawable.ic_fluent_animal_dog_24_regular, R.string.sk_icon_dog), + RABBIT(R.drawable.ic_fluent_animal_rabbit_24_regular, R.string.sk_icon_rabbit), + TURTLE(R.drawable.ic_fluent_animal_turtle_24_regular, R.string.sk_icon_turtle), + ACADEMIC_CAP(R.drawable.ic_fluent_hat_graduation_24_regular, R.string.sk_icon_academic_cap), + BOT(R.drawable.ic_fluent_bot_24_regular, R.string.sk_icon_bot), + IMPORTANT(R.drawable.ic_fluent_important_24_regular, R.string.sk_icon_important), + PIN(R.drawable.ic_fluent_pin_24_regular, R.string.sk_icon_pin), + SHIELD(R.drawable.ic_fluent_shield_24_regular, R.string.sk_icon_shield), + CHAT(R.drawable.ic_fluent_chat_multiple_24_regular, R.string.sk_icon_chat), + TAG(R.drawable.ic_fluent_tag_24_regular, R.string.sk_icon_tag), + TRAIN(R.drawable.ic_fluent_vehicle_subway_24_regular, R.string.sk_icon_train), + BICYCLE(R.drawable.ic_fluent_vehicle_bicycle_24_regular, R.string.sk_icon_bicycle), + MAP(R.drawable.ic_fluent_map_24_regular, R.string.sk_icon_map), + BACKPACK(R.drawable.ic_fluent_backpack_24_regular, R.string.sk_icon_backpack), + BRIEFCASE(R.drawable.ic_fluent_briefcase_24_regular, R.string.sk_icon_briefcase), + BOOK(R.drawable.ic_fluent_book_open_24_regular, R.string.sk_icon_book), + LANGUAGE(R.drawable.ic_fluent_local_language_24_regular, R.string.sk_icon_language), + WEATHER(R.drawable.ic_fluent_weather_rain_showers_day_24_regular, R.string.sk_icon_weather), + APERTURE(R.drawable.ic_fluent_scan_24_regular, R.string.sk_icon_aperture), + MUSIC(R.drawable.ic_fluent_music_note_2_24_regular, R.string.sk_icon_music), + LOCATION(R.drawable.ic_fluent_location_24_regular, R.string.sk_icon_location), + GLOBE(R.drawable.ic_fluent_globe_24_regular, R.string.sk_icon_globe), + MEGAPHONE(R.drawable.ic_fluent_megaphone_loud_24_regular, R.string.sk_icon_megaphone), + MICROPHONE(R.drawable.ic_fluent_mic_24_regular, R.string.sk_icon_microphone), + MICROSCOPE(R.drawable.ic_fluent_microscope_24_regular, R.string.sk_icon_microscope), + STETHOSCOPE(R.drawable.ic_fluent_stethoscope_24_regular, R.string.sk_icon_stethoscope), + KEYBOARD(R.drawable.ic_fluent_midi_24_regular, R.string.sk_icon_keyboard), + COFFEE(R.drawable.ic_fluent_drink_coffee_24_regular, R.string.sk_icon_coffee), + CLAPPER_BOARD(R.drawable.ic_fluent_movies_and_tv_24_regular, R.string.sk_icon_clapper_board), + LAUGH(R.drawable.ic_fluent_emoji_laugh_24_regular, R.string.sk_icon_laugh), + BALLOON(R.drawable.ic_fluent_balloon_24_regular, R.string.sk_icon_balloon), + PI(R.drawable.ic_fluent_pi_24_regular, R.string.sk_icon_pi), + MATH_FORMULA(R.drawable.ic_fluent_math_formula_24_regular, R.string.sk_icon_math_formula), + GAMES(R.drawable.ic_fluent_games_24_regular, R.string.sk_icon_games), + CODE(R.drawable.ic_fluent_code_24_regular, R.string.sk_icon_code), + BUG(R.drawable.ic_fluent_bug_24_regular, R.string.sk_icon_bug), + LIGHT_BULB(R.drawable.ic_fluent_lightbulb_24_regular, R.string.sk_icon_light_bulb), + FIRE(R.drawable.ic_fluent_fire_24_regular, R.string.sk_icon_fire), + LEAVES(R.drawable.ic_fluent_leaf_three_24_regular, R.string.sk_icon_leaves), + SPORT(R.drawable.ic_fluent_sport_24_regular, R.string.sk_icon_sport), + HEALTH(R.drawable.ic_fluent_heart_pulse_24_regular, R.string.sk_icon_health), + PIZZA(R.drawable.ic_fluent_food_pizza_24_regular, R.string.sk_icon_pizza), + GAVEL(R.drawable.ic_fluent_gavel_24_regular, R.string.sk_icon_gavel), + GAUGE(R.drawable.ic_fluent_gauge_24_regular, R.string.sk_icon_gauge), + HEADPHONES(R.drawable.ic_fluent_headphones_sound_wave_24_regular, R.string.sk_icon_headphones), + HUMAN(R.drawable.ic_fluent_accessibility_24_regular, R.string.sk_icon_human), + + HOME(R.drawable.ic_fluent_home_24_regular, R.string.sk_timeline_home, true), + LOCAL(R.drawable.ic_fluent_people_community_24_regular, R.string.sk_timeline_local, true), + FEDERATED(R.drawable.ic_fluent_earth_24_regular, R.string.sk_timeline_federated, true), + POST_NOTIFICATIONS(R.drawable.ic_fluent_chat_24_regular, R.string.sk_timeline_posts, true), + LIST(R.drawable.ic_fluent_people_24_regular, R.string.sk_list, true), + HASHTAG(R.drawable.ic_fluent_number_symbol_24_regular, R.string.sk_hashtag, true); + + public final int iconRes, nameRes; + public final boolean hidden; + + Icon(@DrawableRes int iconRes, @StringRes int nameRes) { + this(iconRes, nameRes, false); + } + + Icon(@DrawableRes int iconRes, @StringRes int nameRes, boolean hidden) { + this.iconRes = iconRes; + this.nameRes = nameRes; + this.hidden = hidden; + } + } + + public static final TimelineDefinition HOME_TIMELINE = new TimelineDefinition(TimelineType.HOME); + public static final TimelineDefinition LOCAL_TIMELINE = new TimelineDefinition(TimelineType.LOCAL); + public static final TimelineDefinition FEDERATED_TIMELINE = new TimelineDefinition(TimelineType.FEDERATED); + public static final TimelineDefinition POSTS_TIMELINE = new TimelineDefinition(TimelineType.POST_NOTIFICATIONS); + + public static final List DEFAULT_TIMELINES = BuildConfig.BUILD_TYPE.equals("playRelease") + ? List.of(HOME_TIMELINE.copy(), LOCAL_TIMELINE.copy()) + : List.of(HOME_TIMELINE.copy(), LOCAL_TIMELINE.copy(), FEDERATED_TIMELINE.copy()); + public static final List ALL_TIMELINES = List.of( + HOME_TIMELINE.copy(), + LOCAL_TIMELINE.copy(), + FEDERATED_TIMELINE.copy(), + POSTS_TIMELINE.copy() + ); +} 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 2dec70ed8..209a1456d 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 @@ -96,9 +96,10 @@ public class ExtendedFooterStatusDisplayItem extends StatusDisplayItem{ visibility.setImageResource(switch (s.visibility) { case PUBLIC -> R.drawable.ic_fluent_earth_20_regular; - case UNLISTED -> R.drawable.ic_fluent_people_community_20_regular; - case PRIVATE -> R.drawable.ic_fluent_people_checkmark_20_regular; + case UNLISTED -> R.drawable.ic_fluent_lock_open_20_regular; + case PRIVATE -> R.drawable.ic_fluent_lock_closed_20_filled; case DIRECT -> R.drawable.ic_fluent_mention_20_regular; + case LOCAL -> R.drawable.ic_fluent_eye_20_regular; }); } diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/FooterStatusDisplayItem.java b/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/FooterStatusDisplayItem.java index be0df7997..48634cfed 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/FooterStatusDisplayItem.java +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/FooterStatusDisplayItem.java @@ -137,7 +137,7 @@ public class FooterStatusDisplayItem extends StatusDisplayItem{ boost.setSelected(item.status.reblogged); favorite.setSelected(item.status.favourited); bookmark.setSelected(item.status.bookmarked); - boost.setEnabled(item.status.visibility==StatusPrivacy.PUBLIC || item.status.visibility==StatusPrivacy.UNLISTED + boost.setEnabled(item.status.visibility==StatusPrivacy.PUBLIC || item.status.visibility==StatusPrivacy.UNLISTED || item.status.visibility==StatusPrivacy.LOCAL || (item.status.visibility==StatusPrivacy.PRIVATE && item.status.account.id.equals(AccountSessionManager.getInstance().getAccount(item.accountID).self.id))); } @@ -239,8 +239,8 @@ public class FooterStatusDisplayItem extends StatusDisplayItem{ Drawable checkMark = ctx.getDrawable(R.drawable.ic_fluent_checkmark_circle_20_regular); Drawable publicDrawable = ctx.getDrawable(R.drawable.ic_fluent_earth_24_regular); - Drawable unlistedDrawable = ctx.getDrawable(R.drawable.ic_fluent_people_community_24_regular); - Drawable followersDrawable = ctx.getDrawable(R.drawable.ic_fluent_people_checkmark_24_regular); + Drawable unlistedDrawable = ctx.getDrawable(R.drawable.ic_fluent_lock_open_24_regular); + Drawable followersDrawable = ctx.getDrawable(R.drawable.ic_fluent_lock_closed_24_regular); StatusPrivacy defaultVisibility = session.preferences != null ? session.preferences.postingDefaultVisibility : null; // e.g. post visibility is unlisted, but default is public diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/HeaderStatusDisplayItem.java b/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/HeaderStatusDisplayItem.java index 946eeb3d9..f5ce471cf 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/HeaderStatusDisplayItem.java +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/HeaderStatusDisplayItem.java @@ -9,7 +9,6 @@ import android.os.Build; import android.os.Bundle; import android.text.SpannableStringBuilder; import android.text.TextUtils; -import android.util.TypedValue; import android.view.Menu; import android.view.MenuItem; import android.view.SubMenu; @@ -21,6 +20,8 @@ import android.widget.PopupMenu; import android.widget.TextView; import android.widget.Toast; +import androidx.annotation.StringRes; + import org.joinmastodon.android.GlobalUserPreferences; import org.joinmastodon.android.R; import org.joinmastodon.android.api.requests.accounts.GetAccountRelationships; @@ -31,6 +32,7 @@ import org.joinmastodon.android.api.session.AccountSession; import org.joinmastodon.android.api.session.AccountSessionManager; import org.joinmastodon.android.fragments.BaseStatusListFragment; import org.joinmastodon.android.fragments.ComposeFragment; +import org.joinmastodon.android.fragments.ListTimelinesFragment; import org.joinmastodon.android.fragments.NotificationsListFragment; import org.joinmastodon.android.fragments.ProfileFragment; import org.joinmastodon.android.fragments.ThreadFragment; @@ -42,6 +44,7 @@ import org.joinmastodon.android.model.Notification; import org.joinmastodon.android.model.Relationship; import org.joinmastodon.android.model.ScheduledStatus; import org.joinmastodon.android.model.Status; +import org.joinmastodon.android.model.StatusPrivacy; import org.joinmastodon.android.ui.text.HtmlParser; import org.joinmastodon.android.ui.utils.CustomEmojiHelper; import org.joinmastodon.android.ui.utils.UiUtils; @@ -51,6 +54,7 @@ import java.time.Instant; import java.time.ZoneId; import java.time.format.DateTimeFormatter; import java.time.format.FormatStyle; +import java.util.ArrayList; import java.util.Collections; import java.util.List; import java.util.Locale; @@ -135,7 +139,8 @@ public class HeaderStatusDisplayItem extends StatusDisplayItem{ public static class Holder extends StatusDisplayItem.Holder implements ImageLoaderViewHolder{ private final TextView name, username, timestamp, extraText, separator; - private final ImageView avatar, more, visibility, deleteNotification, unreadIndicator; + private final View collapseBtn; + private final ImageView avatar, more, visibility, deleteNotification, unreadIndicator, collapseBtnIcon; private final PopupMenu optionsMenu; private Relationship relationship; private APIRequest currentRelationshipRequest; @@ -158,6 +163,8 @@ public class HeaderStatusDisplayItem extends StatusDisplayItem{ visibility=findViewById(R.id.visibility); deleteNotification=findViewById(R.id.delete_notification); unreadIndicator=findViewById(R.id.unread_indicator); + collapseBtn=findViewById(R.id.collapse_btn); + collapseBtnIcon=findViewById(R.id.collapse_btn_icon); extraText=findViewById(R.id.extra_text); avatar.setOnClickListener(this::onAvaClick); avatar.setOutlineProvider(roundCornersOutline); @@ -169,6 +176,7 @@ public class HeaderStatusDisplayItem extends StatusDisplayItem{ fragment.removeNotification(item.notification); } })); + collapseBtn.setOnClickListener(l -> item.parentFragment.onToggleExpanded(item.status, getItemID())); optionsMenu=new PopupMenu(activity, more); optionsMenu.inflate(R.menu.post); @@ -180,7 +188,8 @@ public class HeaderStatusDisplayItem extends StatusDisplayItem{ final Bundle args=new Bundle(); args.putString("account", item.parentFragment.getAccountID()); args.putParcelable("editStatus", Parcels.wrap(item.status)); - if (id==R.id.delete_and_redraft) { + boolean redraft = id==R.id.delete_and_redraft; + if (redraft) { args.putBoolean("redraftStatus", true); if (item.parentFragment instanceof ThreadFragment thread && !thread.isItemEnabled(item.status.id)) { // ("enabled" = clickable; opened status is not clickable) @@ -188,7 +197,7 @@ public class HeaderStatusDisplayItem extends StatusDisplayItem{ args.putBoolean("navigateToStatus", true); } } - if(TextUtils.isEmpty(item.status.content) && TextUtils.isEmpty(item.status.spoilerText)){ + if(!redraft && TextUtils.isEmpty(item.status.content) && TextUtils.isEmpty(item.status.spoilerText)){ Nav.go(item.parentFragment.getActivity(), ComposeFragment.class, args); }else if(item.scheduledStatus!=null){ args.putString("sourceText", item.status.text); @@ -203,7 +212,7 @@ public class HeaderStatusDisplayItem extends StatusDisplayItem{ public void onSuccess(GetStatusSourceText.Response result){ args.putString("sourceText", result.text); args.putString("sourceSpoiler", result.spoilerText); - if (id==R.id.delete_and_redraft) { + if (redraft) { UiUtils.confirmDeletePost(item.parentFragment.getActivity(), item.parentFragment.getAccountID(), item.status, s->{ Nav.go(item.parentFragment.getActivity(), ComposeFragment.class, args); }, true); @@ -261,6 +270,12 @@ public class HeaderStatusDisplayItem extends StatusDisplayItem{ UiUtils.confirmToggleBlockDomain(activity, item.parentFragment.getAccountID(), account.getDomain(), relationship!=null && relationship.domainBlocking, ()->{}); }else if(id==R.id.bookmark){ AccountSessionManager.getInstance().getAccount(item.accountID).getStatusInteractionController().setBookmarked(item.status, !item.status.bookmarked); + }else if(id==R.id.manage_user_lists){ + final Bundle args=new Bundle(); + args.putString("account", item.parentFragment.getAccountID()); + args.putString("profileAccount", account.id); + args.putString("profileDisplayUsername", account.getDisplayUsername()); + Nav.go(item.parentFragment.getActivity(), ListTimelinesFragment.class, args); } return true; }); @@ -291,7 +306,7 @@ public class HeaderStatusDisplayItem extends StatusDisplayItem{ DateTimeFormatter formatter = DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM).withLocale(Locale.getDefault()); timestamp.setText(item.scheduledStatus.scheduledAt.atZone(ZoneId.systemDefault()).format(formatter)); } - else if ((item.status==null || item.status.editedAt==null) && item.createdAt != null) + else if ((!item.inset || item.status==null || item.status.editedAt==null) && item.createdAt != null) timestamp.setText(UiUtils.formatRelativeTimestamp(itemView.getContext(), item.createdAt)); else if (item.status != null && item.status.editedAt != null) timestamp.setText(item.parentFragment.getString(R.string.edited_timestamp, UiUtils.formatRelativeTimestamp(itemView.getContext(), item.status.editedAt))); @@ -310,12 +325,15 @@ public class HeaderStatusDisplayItem extends StatusDisplayItem{ } itemView.setPadding(itemView.getPaddingLeft(), itemView.getPaddingTop(), itemView.getPaddingRight(), item.needBottomPadding ? V.dp(16) : 0); if(TextUtils.isEmpty(item.extraText)){ - extraText.setVisibility(View.GONE); + if (item.status != null) { + UiUtils.setExtraTextInfo(item.parentFragment.getContext(), extraText, item.status.visibility, item.status.localOnly); + } }else{ extraText.setVisibility(View.VISIBLE); extraText.setText(item.extraText); } - more.setVisibility(item.inset ? View.GONE : View.VISIBLE); + more.setVisibility(item.inset || (item.notification != null && item.notification.report != null) + ? View.GONE : View.VISIBLE); avatar.setClickable(!item.inset); avatar.setContentDescription(item.parentFragment.getString(R.string.avatar_description, item.user.acct)); if(currentRelationshipRequest!=null){ @@ -343,6 +361,7 @@ public class HeaderStatusDisplayItem extends StatusDisplayItem{ public void onSuccess(Object o) { item.consumeReadAnnouncement.accept(item.announcement.id); item.announcement.read = true; + if (item.parentFragment.getActivity() == null) return; rebind(); } @@ -360,6 +379,17 @@ public class HeaderStatusDisplayItem extends StatusDisplayItem{ more.setContentDescription(desc); if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) more.setTooltipText(desc); + + if (item.status == null || !item.status.textExpandable) { + collapseBtn.setVisibility(View.GONE); + } else { + String collapseText = item.parentFragment.getString(item.status.textExpanded ? R.string.sk_collapse : R.string.sk_expand); + collapseBtn.setVisibility(item.status.textExpandable ? View.VISIBLE : View.GONE); + collapseBtn.setContentDescription(collapseText); + if (GlobalUserPreferences.reduceMotion) collapseBtnIcon.setScaleY(item.status.textExpanded ? -1 : 1); + else collapseBtnIcon.animate().scaleY(item.status.textExpanded ? -1 : 1).start(); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) collapseBtn.setTooltipText(collapseText); + } } @Override @@ -415,6 +445,7 @@ public class HeaderStatusDisplayItem extends StatusDisplayItem{ } private void updateOptionsMenu(){ + if (item.parentFragment.getActivity() == null) return; if (item.announcement != null) return; boolean hasMultipleAccounts = AccountSessionManager.getInstance().getLoggedInAccounts().size() > 1; Menu menu=optionsMenu.getMenu(); @@ -445,6 +476,7 @@ public class HeaderStatusDisplayItem extends StatusDisplayItem{ MenuItem block=menu.findItem(R.id.block); MenuItem report=menu.findItem(R.id.report); MenuItem follow=menu.findItem(R.id.follow); + MenuItem manageUserLists = menu.findItem(R.id.manage_user_lists); MenuItem bookmark=menu.findItem(R.id.bookmark); bookmark.setVisible(false); /* disabled in megalodon: add/remove bookmark is already available through status footer @@ -461,6 +493,7 @@ public class HeaderStatusDisplayItem extends StatusDisplayItem{ report.setVisible(false); follow.setVisible(false); blockDomain.setVisible(false); + manageUserLists.setVisible(false); }else{ mute.setVisible(true); block.setVisible(true); @@ -481,6 +514,8 @@ public class HeaderStatusDisplayItem extends StatusDisplayItem{ boolean following = relationship!=null && relationship.following; follow.setTitle(item.parentFragment.getString(following ? R.string.unfollow_user : R.string.follow_user, account.getShortUsername())); follow.setIcon(following ? R.drawable.ic_fluent_person_delete_24_regular : R.drawable.ic_fluent_person_add_24_regular); + manageUserLists.setVisible(relationship != null && relationship.following); + manageUserLists.setTitle(item.parentFragment.getString(R.string.sk_lists_with_user, account.getShortUsername())); UiUtils.insetPopupMenuIcon(item.parentFragment.getContext(), follow); } } diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/ImageStatusDisplayItem.java b/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/ImageStatusDisplayItem.java index 782f94e17..b76cb997b 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/ImageStatusDisplayItem.java +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/ImageStatusDisplayItem.java @@ -1,12 +1,21 @@ package org.joinmastodon.android.ui.displayitems; +import android.animation.Animator; +import android.animation.AnimatorListenerAdapter; +import android.animation.AnimatorSet; +import android.animation.ObjectAnimator; import android.app.Activity; import android.graphics.drawable.Drawable; import android.text.TextUtils; import android.view.View; import android.view.ViewGroup; +import android.view.ViewTreeObserver; +import android.widget.FrameLayout; +import android.widget.ImageButton; import android.widget.ImageView; +import android.widget.TextView; +import org.joinmastodon.android.GlobalUserPreferences; import org.joinmastodon.android.R; import org.joinmastodon.android.fragments.BaseStatusListFragment; import org.joinmastodon.android.model.Attachment; @@ -19,6 +28,7 @@ import org.joinmastodon.android.ui.views.ImageAttachmentFrameLayout; import androidx.annotation.LayoutRes; import me.grishka.appkit.imageloader.ImageLoaderViewHolder; import me.grishka.appkit.imageloader.requests.ImageLoaderRequest; +import me.grishka.appkit.utils.CubicBezierInterpolator; public abstract class ImageStatusDisplayItem extends StatusDisplayItem{ public final int index; @@ -56,11 +66,35 @@ public abstract class ImageStatusDisplayItem extends StatusDisplayItem{ private BlurhashCrossfadeDrawable crossfadeDrawable=new BlurhashCrossfadeDrawable(); private boolean didClear; + private AnimatorSet currentAnim; + private final FrameLayout altTextWrapper; + private final TextView altTextButton; + private final ImageView noAltTextButton; + private final View altTextScroller; + private final ImageButton altTextClose; + private final TextView altText, noAltText; + + private View altOrNoAltButton; + private boolean altTextShown; + public Holder(Activity activity, @LayoutRes int layout, ViewGroup parent){ super(activity, layout, parent); photo=findViewById(R.id.photo); photo.setOnClickListener(this::onViewClick); this.layout=(ImageAttachmentFrameLayout)itemView; + + altTextWrapper=findViewById(R.id.alt_text_wrapper); + altTextButton=findViewById(R.id.alt_button); + noAltTextButton=findViewById(R.id.no_alt_button); + altTextScroller=findViewById(R.id.alt_text_scroller); + altTextClose=findViewById(R.id.alt_text_close); + altText=findViewById(R.id.alt_text); + noAltText=findViewById(R.id.no_alt_text); + + altTextButton.setOnClickListener(this::onShowHideClick); + noAltTextButton.setOnClickListener(this::onShowHideClick); + altTextClose.setOnClickListener(this::onShowHideClick); +// altTextScroller.setNestedScrollingEnabled(true); } @Override @@ -73,6 +107,111 @@ public abstract class ImageStatusDisplayItem extends StatusDisplayItem{ photo.setImageDrawable(crossfadeDrawable); photo.setContentDescription(TextUtils.isEmpty(item.attachment.description) ? item.parentFragment.getString(R.string.media_no_description) : item.attachment.description); didClear=false; + + if (currentAnim != null) currentAnim.cancel(); + + boolean altTextMissing = TextUtils.isEmpty(item.attachment.description); + altOrNoAltButton = altTextMissing ? noAltTextButton : altTextButton; + altTextShown=false; + + altTextScroller.setVisibility(View.GONE); + altTextClose.setVisibility(View.GONE); + altTextButton.setVisibility(View.VISIBLE); + noAltTextButton.setVisibility(View.VISIBLE); + altTextButton.setAlpha(1f); + noAltTextButton.setAlpha(1f); + altTextWrapper.setVisibility(View.VISIBLE); + + if (altTextMissing){ + if (GlobalUserPreferences.showNoAltIndicator) { + noAltTextButton.setVisibility(View.VISIBLE); + noAltText.setVisibility(View.VISIBLE); + altTextWrapper.setBackgroundResource(R.drawable.bg_image_no_alt_overlay); + altTextButton.setVisibility(View.GONE); + altText.setVisibility(View.GONE); + } else { + altTextWrapper.setVisibility(View.GONE); + } + }else{ + if (GlobalUserPreferences.showAltIndicator) { + noAltTextButton.setVisibility(View.GONE); + noAltText.setVisibility(View.GONE); + altTextWrapper.setBackgroundResource(R.drawable.bg_image_alt_overlay); + altTextButton.setVisibility(View.VISIBLE); + altTextButton.setText(R.string.sk_alt_button); + altText.setVisibility(View.VISIBLE); + altText.setText(item.attachment.description); + altText.setPadding(0, 0, 0, 0); + } else { + altTextWrapper.setVisibility(View.GONE); + } + } + } + + private void onShowHideClick(View v){ + boolean show=v.getId()==R.id.alt_button || v.getId()==R.id.no_alt_button; + + if(altTextShown==show) + return; + if(currentAnim!=null) + currentAnim.cancel(); + + altTextShown=show; + if(show){ + altTextScroller.setVisibility(View.VISIBLE); + altTextClose.setVisibility(View.VISIBLE); + }else{ + altOrNoAltButton.setVisibility(View.VISIBLE); + // Hide these views temporarily so FrameLayout measures correctly + altTextScroller.setVisibility(View.GONE); + altTextClose.setVisibility(View.GONE); + } + + // This is the current size... + int prevLeft=altTextWrapper.getLeft(); + int prevRight=altTextWrapper.getRight(); + int prevTop=altTextWrapper.getTop(); + altTextWrapper.getViewTreeObserver().addOnPreDrawListener(new ViewTreeObserver.OnPreDrawListener(){ + @Override + public boolean onPreDraw(){ + altTextWrapper.getViewTreeObserver().removeOnPreDrawListener(this); + + // ...and this is after the layout pass, right now the FrameLayout has its final size, but we animate that change + if(!show){ + // Show these views again so they're visible for the duration of the animation. + // No one would notice they were missing during measure/layout. + altTextScroller.setVisibility(View.VISIBLE); + altTextClose.setVisibility(View.VISIBLE); + } + AnimatorSet set=new AnimatorSet(); + set.playTogether( + ObjectAnimator.ofInt(altTextWrapper, "left", prevLeft, altTextWrapper.getLeft()), + ObjectAnimator.ofInt(altTextWrapper, "right", prevRight, altTextWrapper.getRight()), + ObjectAnimator.ofInt(altTextWrapper, "top", prevTop, altTextWrapper.getTop()), + ObjectAnimator.ofFloat(altOrNoAltButton, View.ALPHA, show ? 1f : 0f, show ? 0f : 1f), + ObjectAnimator.ofFloat(altTextScroller, View.ALPHA, show ? 0f : 1f, show ? 1f : 0f), + ObjectAnimator.ofFloat(altTextClose, View.ALPHA, show ? 0f : 1f, show ? 1f : 0f) + ); + set.setDuration(300); + set.setInterpolator(CubicBezierInterpolator.DEFAULT); + set.addListener(new AnimatorListenerAdapter(){ + @Override + public void onAnimationEnd(Animator animation){ + if(show){ + altOrNoAltButton.setVisibility(View.GONE); + }else{ + altTextScroller.setVisibility(View.GONE); + altTextClose.setVisibility(View.GONE); + } + currentAnim=null; + } + }); + set.start(); + currentAnim=set; + + return true; + } + }); } @Override diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/PhotoStatusDisplayItem.java b/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/PhotoStatusDisplayItem.java index 964f57b4f..a0609d10c 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/PhotoStatusDisplayItem.java +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/PhotoStatusDisplayItem.java @@ -1,8 +1,21 @@ package org.joinmastodon.android.ui.displayitems; +import android.animation.Animator; +import android.animation.AnimatorListenerAdapter; +import android.animation.AnimatorSet; +import android.animation.ObjectAnimator; import android.app.Activity; +import android.text.TextUtils; +import android.view.View; import android.view.ViewGroup; +import android.view.ViewTreeObserver; +import android.widget.FrameLayout; +import android.widget.ImageButton; +import android.widget.ImageView; +import android.widget.ScrollView; +import android.widget.TextView; +import org.joinmastodon.android.GlobalUserPreferences; import org.joinmastodon.android.R; import org.joinmastodon.android.fragments.BaseStatusListFragment; import org.joinmastodon.android.model.Attachment; @@ -10,6 +23,8 @@ import org.joinmastodon.android.model.Status; import org.joinmastodon.android.ui.PhotoLayoutHelper; import me.grishka.appkit.imageloader.requests.UrlImageLoaderRequest; +import me.grishka.appkit.utils.CubicBezierInterpolator; +import me.grishka.appkit.utils.V; public class PhotoStatusDisplayItem extends ImageStatusDisplayItem{ public PhotoStatusDisplayItem(String parentID, Status status, Attachment photo, BaseStatusListFragment parentFragment, int index, int totalPhotos, PhotoLayoutHelper.TiledLayoutResult tiledLayout, PhotoLayoutHelper.TiledLayoutResult.Tile thisTile){ @@ -22,9 +37,8 @@ public class PhotoStatusDisplayItem extends ImageStatusDisplayItem{ return Type.PHOTO; } - public static class Holder extends ImageStatusDisplayItem.Holder{ - - public Holder(Activity activity, ViewGroup parent){ + public static class Holder extends ImageStatusDisplayItem.Holder { + public Holder(Activity activity, ViewGroup parent) { super(activity, R.layout.display_item_photo, parent); } } diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/PollFooterStatusDisplayItem.java b/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/PollFooterStatusDisplayItem.java index e4221cf9f..ed73e4b44 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/PollFooterStatusDisplayItem.java +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/PollFooterStatusDisplayItem.java @@ -39,10 +39,11 @@ public class PollFooterStatusDisplayItem extends StatusDisplayItem{ @Override public void onBind(PollFooterStatusDisplayItem item){ String text=item.parentFragment.getResources().getQuantityString(R.plurals.x_voters, item.poll.votersCount, item.poll.votersCount); + String sep=item.parentFragment.getString(R.string.sk_separator); if(item.poll.expiresAt!=null && !item.poll.isExpired()){ - text+=" · "+UiUtils.formatTimeLeft(itemView.getContext(), item.poll.expiresAt); + text+=" "+sep+" "+UiUtils.formatTimeLeft(itemView.getContext(), item.poll.expiresAt); }else if(item.poll.isExpired()){ - text+=" · "+item.parentFragment.getString(R.string.poll_closed); + text+=" "+sep+" "+item.parentFragment.getString(R.string.poll_closed); } this.text.setText(text); button.setVisibility(item.poll.isExpired() || item.poll.voted || (!item.poll.multiple && !GlobalUserPreferences.voteButtonForSingleChoice) ? View.GONE : View.VISIBLE); diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/ReblogOrReplyLineStatusDisplayItem.java b/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/ReblogOrReplyLineStatusDisplayItem.java index c1695a90a..366d5ba9f 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/ReblogOrReplyLineStatusDisplayItem.java +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/ReblogOrReplyLineStatusDisplayItem.java @@ -55,8 +55,8 @@ public class ReblogOrReplyLineStatusDisplayItem extends StatusDisplayItem{ this.visibility = visibility; this.iconEnd = visibility != null ? switch (visibility) { case PUBLIC -> R.drawable.ic_fluent_earth_20_regular; - case UNLISTED -> R.drawable.ic_fluent_people_community_20_regular; - case PRIVATE -> R.drawable.ic_fluent_people_checkmark_20_regular; + case UNLISTED -> R.drawable.ic_fluent_lock_open_20_regular; + case PRIVATE -> R.drawable.ic_fluent_lock_closed_20_filled; default -> 0; } : 0; } 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 36e907616..012f06596 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 @@ -10,23 +10,31 @@ import android.view.ViewGroup; import org.joinmastodon.android.R; import org.joinmastodon.android.api.session.AccountSessionManager; import org.joinmastodon.android.fragments.BaseStatusListFragment; +import org.joinmastodon.android.fragments.HashtagTimelineFragment; +import org.joinmastodon.android.fragments.HomeTabFragment; +import org.joinmastodon.android.fragments.HomeTimelineFragment; +import org.joinmastodon.android.fragments.ListTimelineFragment; import org.joinmastodon.android.fragments.ProfileFragment; import org.joinmastodon.android.fragments.ThreadFragment; import org.joinmastodon.android.model.Account; import org.joinmastodon.android.model.Attachment; import org.joinmastodon.android.model.DisplayItemsParent; +import org.joinmastodon.android.model.Filter; +import org.joinmastodon.android.model.Hashtag; import org.joinmastodon.android.model.Notification; import org.joinmastodon.android.model.Poll; import org.joinmastodon.android.model.ScheduledStatus; import org.joinmastodon.android.model.Status; import org.joinmastodon.android.ui.PhotoLayoutHelper; import org.joinmastodon.android.ui.text.HtmlParser; +import org.joinmastodon.android.utils.StatusFilterPredicate; import org.parceler.Parcels; import java.util.ArrayList; import java.util.List; import java.util.Map; import java.util.Objects; +import java.util.Optional; import java.util.stream.Collectors; import me.grishka.appkit.Nav; @@ -73,17 +81,39 @@ public abstract class StatusDisplayItem{ case HASHTAG -> new HashtagStatusDisplayItem.Holder(activity, parent); case GAP -> new GapStatusDisplayItem.Holder(activity, parent); case EXTENDED_FOOTER -> new ExtendedFooterStatusDisplayItem.Holder(activity, parent); + case WARNING -> new WarningFilteredStatusDisplayItem.Holder(activity, parent); }; } - public static ArrayList buildItems(BaseStatusListFragment fragment, Status status, String accountID, DisplayItemsParent parentObject, Map knownAccounts, boolean inset, boolean addFooter, Notification notification){ + public static ArrayList buildItems(BaseStatusListFragment fragment, Status status, String accountID, DisplayItemsParent parentObject, Map knownAccounts, boolean inset, boolean addFooter, Notification notification){ + return buildItems(fragment, status, accountID, parentObject, knownAccounts, inset, addFooter, notification, false, Filter.FilterContext.HOME); + } + + public static ArrayList buildItems(BaseStatusListFragment fragment, Status status, String accountID, DisplayItemsParent parentObject, Map knownAccounts, boolean inset, boolean addFooter, Notification notification, Filter.FilterContext filterContext){ + return buildItems(fragment, status, accountID, parentObject, knownAccounts, inset, addFooter, notification, false, filterContext); + } + + public static ArrayList buildItems(BaseStatusListFragment fragment, Status status, String accountID, DisplayItemsParent parentObject, Map knownAccounts, boolean inset, boolean addFooter, Notification notification, boolean disableTranslate){ + return buildItems(fragment, status, accountID, parentObject, knownAccounts, inset, addFooter, notification, disableTranslate, Filter.FilterContext.HOME); + } + + public static ArrayList buildItems(BaseStatusListFragment fragment, Status status, String accountID, DisplayItemsParent parentObject, Map knownAccounts, boolean inset, boolean addFooter, Notification notification, boolean disableTranslate, Filter.FilterContext filterContext){ String parentID=parentObject.getID(); ArrayList items=new ArrayList<>(); + Status statusForContent=status.getContentStatus(); Bundle args=new Bundle(); args.putString("account", accountID); ScheduledStatus scheduledStatus = parentObject instanceof ScheduledStatus ? (ScheduledStatus) parentObject : null; + List filters = AccountSessionManager.getInstance().getAccount(accountID).wordFilters.stream() + .filter(f -> f.context.contains(filterContext)).collect(Collectors.toList()); + StatusFilterPredicate filterPredicate = new StatusFilterPredicate(filters); + + if(!statusForContent.filterRevealed){ + statusForContent.filterRevealed = filterPredicate.testWithWarning(status); + } + if(status.reblog!=null){ boolean isOwnPost = AccountSessionManager.getInstance().isSelf(fragment.getAccountID(), status.account); items.add(new ReblogOrReplyLineStatusDisplayItem(parentID, fragment, fragment.getString(R.string.user_boosted, status.account.displayName), status.account.emojis, R.drawable.ic_fluent_arrow_repeat_all_20_filled, isOwnPost ? status.visibility : null, i->{ @@ -96,11 +126,30 @@ public abstract class StatusDisplayItem{ args.putParcelable("profileAccount", Parcels.wrap(account)); Nav.go(fragment.getActivity(), ProfileFragment.class, args); })); + } else if ( + !(status.tags.isEmpty() || + fragment instanceof HashtagTimelineFragment || + fragment instanceof ListTimelineFragment + ) && fragment.getParentFragment() instanceof HomeTabFragment home + ) { + home.getHashtags().stream() + .filter(followed -> status.tags.stream() + .anyMatch(hashtag -> followed.name.equalsIgnoreCase(hashtag.name))) + .findAny() + // post contains a hashtag the user is following + .ifPresent(hashtag -> items.add(new ReblogOrReplyLineStatusDisplayItem( + parentID, fragment, hashtag.name, List.of(), + R.drawable.ic_fluent_number_symbol_20_filled, null, + i -> { + args.putString("hashtag", hashtag.name); + Nav.go(fragment.getActivity(), HashtagTimelineFragment.class, args); + } + ))); } HeaderStatusDisplayItem header; items.add(header=new HeaderStatusDisplayItem(parentID, statusForContent.account, statusForContent.createdAt, fragment, accountID, statusForContent, null, notification, scheduledStatus)); if(!TextUtils.isEmpty(statusForContent.content)) - items.add(new TextStatusDisplayItem(parentID, HtmlParser.parse(statusForContent.content, statusForContent.emojis, statusForContent.mentions, statusForContent.tags, accountID), fragment, statusForContent)); + items.add(new TextStatusDisplayItem(parentID, HtmlParser.parse(statusForContent.content, statusForContent.emojis, statusForContent.mentions, statusForContent.tags, accountID), fragment, statusForContent, disableTranslate)); else header.needBottomPadding=true; List imageAttachments=statusForContent.mediaAttachments.stream().filter(att->att.type.isImage()).collect(Collectors.toList()); @@ -141,6 +190,13 @@ public abstract class StatusDisplayItem{ item.inset=inset; item.index=i++; } + + if (!statusForContent.filterRevealed) { + return new ArrayList<>(List.of( + new WarningFilteredStatusDisplayItem(parentID, fragment, statusForContent, items) + )); + } + return items; } @@ -167,6 +223,7 @@ public abstract class StatusDisplayItem{ ACCOUNT, HASHTAG, GAP, + WARNING, EXTENDED_FOOTER } @@ -176,7 +233,7 @@ public abstract class StatusDisplayItem{ } public Holder(Context context, int layout, ViewGroup parent){ - super(context, layout, parent); + super(context, layout, parent); } public String getItemID(){ 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 19633096e..6e94d4195 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 @@ -3,14 +3,17 @@ package org.joinmastodon.android.ui.displayitems; import android.app.Activity; import android.graphics.drawable.Animatable; import android.graphics.drawable.Drawable; -import android.graphics.drawable.LayerDrawable; import android.text.TextUtils; -import android.util.TypedValue; import android.view.View; import android.view.ViewGroup; import android.widget.LinearLayout; import android.widget.Button; +import android.widget.ScrollView; import android.widget.TextView; +import android.widget.Toast; + +import com.github.bottomSoftwareFoundation.bottom.Bottom; +import com.github.bottomSoftwareFoundation.bottom.TranslationError; import org.joinmastodon.android.GlobalUserPreferences; import org.joinmastodon.android.R; @@ -20,13 +23,15 @@ import org.joinmastodon.android.api.session.AccountSessionManager; import org.joinmastodon.android.fragments.BaseStatusListFragment; import org.joinmastodon.android.model.Instance; import org.joinmastodon.android.model.Status; -import org.joinmastodon.android.ui.drawables.SpoilerStripesDrawable; import org.joinmastodon.android.model.StatusPrivacy; import org.joinmastodon.android.model.TranslatedStatus; import org.joinmastodon.android.ui.text.HtmlParser; import org.joinmastodon.android.ui.utils.CustomEmojiHelper; import org.joinmastodon.android.ui.utils.UiUtils; import org.joinmastodon.android.ui.views.LinkedTextView; +import org.joinmastodon.android.utils.StatusTextEncoder; + +import java.util.regex.Pattern; import me.grishka.appkit.api.Callback; import me.grishka.appkit.api.ErrorResponse; @@ -42,14 +47,17 @@ public class TextStatusDisplayItem extends StatusDisplayItem{ private CharSequence parsedSpoilerText; public boolean textSelectable; public final Status status; + public boolean disableTranslate; public boolean translated = false; public TranslatedStatus translation = null; private AccountSession session; + 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"); - public TextStatusDisplayItem(String parentID, CharSequence text, BaseStatusListFragment parentFragment, Status status){ + public TextStatusDisplayItem(String parentID, CharSequence text, BaseStatusListFragment parentFragment, Status status, boolean disableTranslate){ super(parentID, parentFragment); this.text=text; this.status=status; + this.disableTranslate=disableTranslate; emojiHelper.setText(text); if(!TextUtils.isEmpty(status.spoilerText)){ parsedSpoilerText=HtmlParser.parseCustomEmoji(status.spoilerText, status.emojis); @@ -81,10 +89,14 @@ public class TextStatusDisplayItem extends StatusDisplayItem{ public static class Holder extends StatusDisplayItem.Holder implements ImageLoaderViewHolder{ private final LinkedTextView text; private final LinearLayout spoilerHeader; - private final TextView spoilerTitle, spoilerTitleInline, translateInfo; - private final View spoilerOverlay, borderTop, borderBottom, textWrap, translateWrap, translateProgress; + private final TextView spoilerTitle, spoilerTitleInline, translateInfo, readMore; + private final View spoilerOverlay, borderTop, borderBottom, textWrap, translateWrap, translateProgress, spaceBelowText; private final int backgroundColor, borderColor; private final Button translateButton; + private final ScrollView textScrollView; + + private final float textMaxHeight, textCollapsedHeight; + private final LinearLayout.LayoutParams collapseParams, wrapParams; public Holder(Activity activity, ViewGroup parent){ super(activity, R.layout.display_item_text, parent); @@ -103,6 +115,14 @@ public class TextStatusDisplayItem extends StatusDisplayItem{ itemView.setOnClickListener(v->item.parentFragment.onRevealSpoilerClick(this)); backgroundColor=UiUtils.getThemeColor(activity, R.attr.colorBackgroundLight); borderColor=UiUtils.getThemeColor(activity, R.attr.colorPollVoted); + textScrollView=findViewById(R.id.text_scroll_view); + readMore=findViewById(R.id.read_more); + spaceBelowText=findViewById(R.id.space_below_text); + textMaxHeight=activity.getResources().getDimension(R.dimen.text_max_height); + textCollapsedHeight=activity.getResources().getDimension(R.dimen.text_collapsed_height); + collapseParams=new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, (int) textCollapsedHeight); + wrapParams=new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT); + readMore.setOnClickListener(v -> item.parentFragment.onToggleExpanded(item.status, getItemID())); } @Override @@ -111,6 +131,9 @@ public class TextStatusDisplayItem extends StatusDisplayItem{ ? HtmlParser.parse(item.translation.content, item.status.emojis, item.status.mentions, item.status.tags, item.parentFragment.getAccountID()) : item.text); text.setTextIsSelectable(item.textSelectable); + if (item.textSelectable) { + textScrollView.setLayoutParams(new LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT)); + } spoilerTitleInline.setTextIsSelectable(item.textSelectable); text.setInvalidateOnEveryFrame(false); spoilerTitleInline.setBackgroundColor(item.inset ? 0 : backgroundColor); @@ -139,19 +162,34 @@ public class TextStatusDisplayItem extends StatusDisplayItem{ } Instance instanceInfo = AccountSessionManager.getInstance().getInstanceInfo(item.session.domain); - boolean translateEnabled = instanceInfo.v2 != null && instanceInfo.v2.configuration.translation != null && instanceInfo.v2.configuration.translation.enabled; + boolean translateEnabled = !item.disableTranslate && instanceInfo != null && + instanceInfo.v2 != null && instanceInfo.v2.configuration.translation != null && + instanceInfo.v2.configuration.translation.enabled; - translateWrap.setVisibility( - (!GlobalUserPreferences.translateButtonOpenedOnly || item.textSelectable) && + boolean isBottomText = BOTTOM_TEXT_PATTERN.matcher(item.status.getStrippedText()).find(); + boolean translateVisible = (isBottomText || ( translateEnabled && - !item.status.visibility.isLessVisibleThan(StatusPrivacy.UNLISTED) && - item.status.language != null && - (item.session.preferences == null || !item.status.language.equalsIgnoreCase(item.session.preferences.postingDefaultLanguage)) - ? View.VISIBLE : View.GONE); + !item.status.visibility.isLessVisibleThan(StatusPrivacy.UNLISTED) && + item.status.language != null && + (item.session.preferences == null || !item.status.language.equalsIgnoreCase(item.session.preferences.postingDefaultLanguage)))) + && (!GlobalUserPreferences.translateButtonOpenedOnly || item.textSelectable); + translateWrap.setVisibility(translateVisible ? View.VISIBLE : View.GONE); translateButton.setText(item.translated ? R.string.sk_translate_show_original : R.string.sk_translate_post); - translateInfo.setText(item.translated ? itemView.getResources().getString(R.string.sk_translated_using, item.translation.provider) : ""); + translateInfo.setText(item.translated ? itemView.getResources().getString(R.string.sk_translated_using, isBottomText ? "bottom-java" : item.translation.provider) : ""); translateButton.setOnClickListener(v->{ if (item.translation == null) { + if (isBottomText) { + try { + item.translation = new TranslatedStatus(); + item.translation.content = new StatusTextEncoder(Bottom::decode).decode(item.status.getStrippedText(), BOTTOM_TEXT_PATTERN); + item.translated = true; + } catch (TranslationError err) { + item.translation = null; + Toast.makeText(itemView.getContext(), err.getLocalizedMessage(), Toast.LENGTH_SHORT).show(); + } + rebind(); + return; + } translateProgress.setVisibility(View.VISIBLE); translateButton.setClickable(false); translateButton.animate().alpha(0.5f).setInterpolator(CubicBezierInterpolator.DEFAULT).setDuration(150).start(); @@ -160,6 +198,7 @@ public class TextStatusDisplayItem extends StatusDisplayItem{ public void onSuccess(TranslatedStatus translatedStatus) { item.translation = translatedStatus; item.translated = true; + if (item.parentFragment.getActivity() == null) return; translateProgress.setVisibility(View.GONE); translateButton.setClickable(true); translateButton.animate().alpha(1).setInterpolator(CubicBezierInterpolator.DEFAULT).setDuration(50).start(); @@ -179,6 +218,26 @@ public class TextStatusDisplayItem extends StatusDisplayItem{ rebind(); } }); + + readMore.setText(item.status.textExpanded ? R.string.sk_collapse : R.string.sk_expand); + spaceBelowText.setVisibility(translateVisible ? View.VISIBLE : View.GONE); + + if (!GlobalUserPreferences.collapseLongPosts) { + textScrollView.setLayoutParams(wrapParams); + readMore.setVisibility(View.GONE); + } + + if (GlobalUserPreferences.collapseLongPosts) text.post(() -> { + boolean tooBig = text.getMeasuredHeight() > textMaxHeight; + boolean inTimeline = !item.textSelectable; + boolean hasSpoiler = !TextUtils.isEmpty(item.status.spoilerText); + boolean expandable = inTimeline && tooBig && !hasSpoiler; + item.parentFragment.onEnableExpandable(this, expandable); + }); + + readMore.setVisibility(item.status.textExpandable && !item.status.textExpanded ? View.VISIBLE : View.GONE); + textScrollView.setLayoutParams(item.status.textExpandable && !item.status.textExpanded ? collapseParams : wrapParams); + if (item.status.textExpandable && !translateVisible) spaceBelowText.setVisibility(View.VISIBLE); } @Override diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/WarningFilteredStatusDisplayItem.java b/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/WarningFilteredStatusDisplayItem.java new file mode 100644 index 000000000..fd1e91259 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/WarningFilteredStatusDisplayItem.java @@ -0,0 +1,52 @@ +package org.joinmastodon.android.ui.displayitems; + +import android.content.Context; +import android.view.View; +import android.view.ViewGroup; +import android.widget.TextView; + +import org.joinmastodon.android.R; +import org.joinmastodon.android.fragments.BaseStatusListFragment; +import org.joinmastodon.android.model.Status; + +import java.util.List; + +public class WarningFilteredStatusDisplayItem extends StatusDisplayItem{ + public boolean loading; + public final Status status; + public List filteredItems; + + public WarningFilteredStatusDisplayItem(String parentID, BaseStatusListFragment parentFragment, Status status, List filteredItems){ + super(parentID, parentFragment); + this.status=status; + this.filteredItems = filteredItems; + } + + @Override + public Type getType(){ + return Type.WARNING; + } + + public static class Holder extends StatusDisplayItem.Holder{ + public final View warningWrap; + public final TextView text; + public List filteredItems; + + public Holder(Context context, ViewGroup parent) { + super(context, R.layout.display_item_filter_warning, parent); + warningWrap=findViewById(R.id.warning_wrap); + text=findViewById(R.id.text); + } + + @Override + public void onBind(WarningFilteredStatusDisplayItem item) { + filteredItems = item.filteredItems; + text.setText(item.parentFragment.getString(R.string.sk_filtered, item.status.filtered.get(item.status.filtered.size() -1).filter.title)); + } + + @Override + public void onClick() { + item.parentFragment.onWarningClick(this); + } + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/tabs/TabLayout.java b/mastodon/src/main/java/org/joinmastodon/android/ui/tabs/TabLayout.java index 33906813a..d30457050 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/ui/tabs/TabLayout.java +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/tabs/TabLayout.java @@ -602,7 +602,7 @@ public class TabLayout extends HorizontalScrollView { *

If the tab indicator color is not {@code Color.TRANSPARENT}, the indicator will be wrapped * and tinted right before it is drawn by {@link SlidingTabIndicator#draw(Canvas)}. If you'd like * the inherent color or the tinted color of a custom drawable to be used, make sure this color is - * set to {@code Color.TRANSPARENT} to avoid your color/tint being overriden. + * set to {@code Color.TRANSPARENT} to avoid your color/tint being overridden. * * @param color color to use for the indicator * @attr ref com.google.android.material.R.styleable#TabLayout_tabIndicatorColor 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 fac4e7bb3..e9c4fb0fc 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 @@ -16,6 +16,10 @@ public class LinkSpan extends CharacterStyle { private String accountID; private String text; + public LinkSpan(String link, OnLinkClickListener listener, Type type, String accountID){ + this(link, listener, type, accountID, null); + } + public LinkSpan(String link, OnLinkClickListener listener, Type type, String accountID, String text){ this.listener=listener; this.link=link; @@ -38,6 +42,7 @@ public class LinkSpan extends CharacterStyle { case URL -> UiUtils.openURL(context, accountID, link); case MENTION -> UiUtils.openProfileByID(context, accountID, link); case HASHTAG -> UiUtils.openHashtagTimeline(context, accountID, link, null); + case CUSTOM -> listener.onLinkClick(this); } } @@ -64,6 +69,7 @@ public class LinkSpan extends CharacterStyle { public enum Type{ URL, MENTION, - HASHTAG + HASHTAG, + CUSTOM } } 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 a0e24d766..edc114101 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 @@ -37,6 +37,7 @@ public class DiscoverInfoBannerHelper{ case TRENDING_LINKS -> R.string.trending_links_info_banner; case LOCAL_TIMELINE -> R.string.local_timeline_info_banner; case FEDERATED_TIMELINE -> R.string.sk_federated_timeline_info_banner; + case POST_NOTIFICATIONS -> R.string.sk_notify_posts_info_banner; }); } } @@ -61,6 +62,7 @@ public class DiscoverInfoBannerHelper{ TRENDING_LINKS, LOCAL_TIMELINE, FEDERATED_TIMELINE, + POST_NOTIFICATIONS, // ACCOUNTS } } 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 b58179bb8..a04316a2a 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 @@ -1,5 +1,6 @@ package org.joinmastodon.android.ui.utils; +import static android.view.Menu.NONE; import static org.joinmastodon.android.GlobalUserPreferences.theme; import static org.joinmastodon.android.GlobalUserPreferences.trueBlackTheme; @@ -27,6 +28,9 @@ import android.os.Build; import android.os.Bundle; import android.os.Handler; import android.os.Looper; +import android.os.SystemClock; +import android.os.ext.SdkExtensions; +import android.provider.MediaStore; import android.provider.OpenableColumns; import android.text.SpannableStringBuilder; import android.text.Spanned; @@ -35,9 +39,12 @@ import android.util.Log; import android.view.HapticFeedbackConstants; import android.view.Menu; import android.view.MenuItem; +import android.view.SubMenu; import android.view.View; import android.webkit.MimeTypeMap; import android.widget.Button; +import android.widget.ImageView; +import android.widget.LinearLayout; import android.widget.PopupMenu; import android.widget.TextView; import android.widget.Toast; @@ -71,18 +78,17 @@ import org.joinmastodon.android.events.StatusDeletedEvent; import org.joinmastodon.android.events.StatusUnpinnedEvent; import org.joinmastodon.android.fragments.ComposeFragment; import org.joinmastodon.android.fragments.HashtagTimelineFragment; -import org.joinmastodon.android.fragments.ListTimelineFragment; import org.joinmastodon.android.fragments.ProfileFragment; import org.joinmastodon.android.fragments.ThreadFragment; import org.joinmastodon.android.model.Account; import org.joinmastodon.android.model.Emoji; import org.joinmastodon.android.model.Instance; -import org.joinmastodon.android.model.ListTimeline; import org.joinmastodon.android.model.Notification; import org.joinmastodon.android.model.Relationship; import org.joinmastodon.android.model.ScheduledStatus; import org.joinmastodon.android.model.SearchResults; import org.joinmastodon.android.model.Status; +import org.joinmastodon.android.model.StatusPrivacy; import org.joinmastodon.android.ui.M3AlertDialogBuilder; import org.joinmastodon.android.ui.text.CustomEmojiSpan; import org.parceler.Parcels; @@ -97,6 +103,7 @@ import java.time.ZoneId; import java.time.ZonedDateTime; import java.time.format.DateTimeFormatter; import java.time.format.FormatStyle; +import java.util.ArrayList; import java.util.Arrays; import java.util.List; import java.util.Map; @@ -123,47 +130,48 @@ import me.grishka.appkit.imageloader.requests.UrlImageLoaderRequest; import me.grishka.appkit.utils.V; import okhttp3.MediaType; -public class UiUtils{ - private static Handler mainHandler=new Handler(Looper.getMainLooper()); - private static final DateTimeFormatter DATE_FORMATTER_SHORT_WITH_YEAR=DateTimeFormatter.ofPattern("d MMM uuuu"), DATE_FORMATTER_SHORT=DateTimeFormatter.ofPattern("d MMM"); - public static final DateTimeFormatter DATE_TIME_FORMATTER=DateTimeFormatter.ofLocalizedDateTime(FormatStyle.LONG, FormatStyle.SHORT); +public class UiUtils { + private static Handler mainHandler = new Handler(Looper.getMainLooper()); + private static final DateTimeFormatter DATE_FORMATTER_SHORT_WITH_YEAR = DateTimeFormatter.ofPattern("d MMM uuuu"), DATE_FORMATTER_SHORT = DateTimeFormatter.ofPattern("d MMM"); + public static final DateTimeFormatter DATE_TIME_FORMATTER = DateTimeFormatter.ofLocalizedDateTime(FormatStyle.LONG, FormatStyle.SHORT); - private UiUtils(){} + private UiUtils() { + } - public static void launchWebBrowser(Context context, String url){ - try{ - if(GlobalUserPreferences.useCustomTabs){ + public static void launchWebBrowser(Context context, String url) { + try { + if (GlobalUserPreferences.useCustomTabs) { new CustomTabsIntent.Builder() .setShowTitle(true) .build() .launchUrl(context, Uri.parse(url)); - }else{ + } else { context.startActivity(new Intent(Intent.ACTION_VIEW, Uri.parse(url))); } - }catch(ActivityNotFoundException x){ + } catch (ActivityNotFoundException x) { Toast.makeText(context, R.string.no_app_to_handle_action, Toast.LENGTH_SHORT).show(); } } - public static String formatRelativeTimestamp(Context context, Instant instant){ - long t=instant.toEpochMilli(); - long now=System.currentTimeMillis(); - long diff=now-t; - if(diff<1000L){ + public static String formatRelativeTimestamp(Context context, Instant instant) { + long t = instant.toEpochMilli(); + long now = System.currentTimeMillis(); + long diff = now - t; + if (diff < 1000L) { return context.getString(R.string.time_now); - }else if(diff<60_000L){ - return context.getString(R.string.time_seconds, diff/1000L); - }else if(diff<3600_000L){ - return context.getString(R.string.time_minutes, diff/60_000L); - }else if(diff<3600_000L*24L){ - return context.getString(R.string.time_hours, diff/3600_000L); - }else{ - int days=(int)(diff/(3600_000L*24L)); - if(days>30){ - ZonedDateTime dt=instant.atZone(ZoneId.systemDefault()); - if(dt.getYear()==ZonedDateTime.now().getYear()){ + } else if (diff < 60_000L) { + return context.getString(R.string.time_seconds, diff / 1000L); + } else if (diff < 3600_000L) { + return context.getString(R.string.time_minutes, diff / 60_000L); + } else if (diff < 3600_000L * 24L) { + return context.getString(R.string.time_hours, diff / 3600_000L); + } else { + int days = (int) (diff / (3600_000L * 24L)); + if (days > 30) { + ZonedDateTime dt = instant.atZone(ZoneId.systemDefault()); + if (dt.getYear() == ZonedDateTime.now().getYear()) { return DATE_FORMATTER_SHORT.format(dt); - }else{ + } else { return DATE_FORMATTER_SHORT_WITH_YEAR.format(dt); } } @@ -171,215 +179,220 @@ public class UiUtils{ } } - public static String formatRelativeTimestampAsMinutesAgo(Context context, Instant instant){ - long t=instant.toEpochMilli(); - long now=System.currentTimeMillis(); - long diff=now-t; - if(diff<1000L){ + public static String formatRelativeTimestampAsMinutesAgo(Context context, Instant instant) { + long t = instant.toEpochMilli(); + long now = System.currentTimeMillis(); + long diff = now - t; + if (diff < 1000L) { return context.getString(R.string.time_just_now); - }else if(diff<60_000L){ - int secs=(int)(diff/1000L); + } else if (diff < 60_000L) { + int secs = (int) (diff / 1000L); return context.getResources().getQuantityString(R.plurals.x_seconds_ago, secs, secs); - }else if(diff<3600_000L){ - int mins=(int)(diff/60_000L); + } else if (diff < 3600_000L) { + int mins = (int) (diff / 60_000L); return context.getResources().getQuantityString(R.plurals.x_minutes_ago, mins, mins); - }else{ + } else { return DATE_TIME_FORMATTER.format(instant.atZone(ZoneId.systemDefault())); } } - public static String formatTimeLeft(Context context, Instant instant){ - long t=instant.toEpochMilli(); - long now=System.currentTimeMillis(); - long diff=t-now; - if(diff<60_000L){ - int secs=(int)(diff/1000L); + public static String formatTimeLeft(Context context, Instant instant) { + long t = instant.toEpochMilli(); + long now = System.currentTimeMillis(); + long diff = t - now; + if (diff < 60_000L) { + int secs = (int) (diff / 1000L); return context.getResources().getQuantityString(R.plurals.x_seconds_left, secs, secs); - }else if(diff<3600_000L){ - int mins=(int)(diff/60_000L); + } else if (diff < 3600_000L) { + int mins = (int) (diff / 60_000L); return context.getResources().getQuantityString(R.plurals.x_minutes_left, mins, mins); - }else if(diff<3600_000L*24L){ - int hours=(int)(diff/3600_000L); + } else if (diff < 3600_000L * 24L) { + int hours = (int) (diff / 3600_000L); return context.getResources().getQuantityString(R.plurals.x_hours_left, hours, hours); - }else{ - int days=(int)(diff/(3600_000L*24L)); + } else { + int days = (int) (diff / (3600_000L * 24L)); return context.getResources().getQuantityString(R.plurals.x_days_left, days, days); } } @SuppressLint("DefaultLocale") - public static String abbreviateNumber(int n){ - if(n<1000){ + public static String abbreviateNumber(int n) { + if (n < 1000) { return String.format("%,d", n); - }else if(n<1_000_000){ - float a=n/1000f; - return a>99f ? String.format("%,dK", (int)Math.floor(a)) : String.format("%,.1fK", a); - }else{ - float a=n/1_000_000f; - return a>99f ? String.format("%,dM", (int)Math.floor(a)) : String.format("%,.1fM", n/1_000_000f); + } else if (n < 1_000_000) { + float a = n / 1000f; + return a > 99f ? String.format("%,dK", (int) Math.floor(a)) : String.format("%,.1fK", a); + } else { + float a = n / 1_000_000f; + return a > 99f ? String.format("%,dM", (int) Math.floor(a)) : String.format("%,.1fM", n / 1_000_000f); } } @SuppressLint("DefaultLocale") - public static String abbreviateNumber(long n){ - if(n<1_000_000_000L) - return abbreviateNumber((int)n); + public static String abbreviateNumber(long n) { + if (n < 1_000_000_000L) + return abbreviateNumber((int) n); - double a=n/1_000_000_000.0; - return a>99f ? String.format("%,dB", (int)Math.floor(a)) : String.format("%,.1fB", n/1_000_000_000.0); + double a = n / 1_000_000_000.0; + return a > 99f ? String.format("%,dB", (int) Math.floor(a)) : String.format("%,.1fB", n / 1_000_000_000.0); } /** * Android 6.0 has a bug where start and end compound drawables don't get tinted. * This works around it by setting the tint colors directly to the drawables. + * * @param textView */ - public static void fixCompoundDrawableTintOnAndroid6(TextView textView){ - Drawable[] drawables=textView.getCompoundDrawablesRelative(); - for(int i=0;i> spansByEmoji=Arrays.stream(spans).collect(Collectors.groupingBy(s->s.emoji)); - for(Map.Entry> emoji:spansByEmoji.entrySet()){ - ViewImageLoader.load(new ViewImageLoader.Target(){ + int emojiSize = V.dp(20); + Map> spansByEmoji = Arrays.stream(spans).collect(Collectors.groupingBy(s -> s.emoji)); + for (Map.Entry> emoji : spansByEmoji.entrySet()) { + ViewImageLoader.load(new ViewImageLoader.Target() { @Override - public void setImageDrawable(Drawable d){ - if(d==null) + public void setImageDrawable(Drawable d) { + if (d == null) return; - for(CustomEmojiSpan span:emoji.getValue()){ + for (CustomEmojiSpan span : emoji.getValue()) { span.setDrawable(d); } view.invalidate(); } @Override - public View getView(){ + public View getView() { return view; } }, null, new UrlImageLoaderRequest(emoji.getKey().url, emojiSize, emojiSize), null, false, true); } } - public static int getThemeColor(Context context, @AttrRes int attr){ - TypedArray ta=context.obtainStyledAttributes(new int[]{attr}); - int color=ta.getColor(0, 0xff00ff00); + public static int getThemeColor(Context context, @AttrRes int attr) { + TypedArray ta = context.obtainStyledAttributes(new int[]{attr}); + int color = ta.getColor(0, 0xff00ff00); ta.recycle(); return color; } - public static void openProfileByID(Context context, String selfID, String id){ - Bundle args=new Bundle(); + public static void openProfileByID(Context context, String selfID, String id) { + Bundle args = new Bundle(); args.putString("account", selfID); args.putString("profileAccountID", id); - Nav.go((Activity)context, ProfileFragment.class, args); + Nav.go((Activity) context, ProfileFragment.class, args); } - public static void openHashtagTimeline(Context context, String accountID, String hashtag, @Nullable Boolean following){ - Bundle args=new Bundle(); + public static void openHashtagTimeline(Context context, String accountID, String hashtag, @Nullable Boolean following) { + Bundle args = new Bundle(); args.putString("account", accountID); args.putString("hashtag", hashtag); if (following != null) args.putBoolean("following", following); - Nav.go((Activity)context, HashtagTimelineFragment.class, args); + Nav.go((Activity) context, HashtagTimelineFragment.class, args); } - public static void showConfirmationAlert(Context context, @StringRes int title, @StringRes int message, @StringRes int confirmButton, Runnable onConfirmed){ + public static void showConfirmationAlert(Context context, @StringRes int title, @StringRes int message, @StringRes int confirmButton, Runnable onConfirmed) { showConfirmationAlert(context, title, message, confirmButton, 0, onConfirmed); } - public static void showConfirmationAlert(Context context, @StringRes int title, @StringRes int message, @StringRes int confirmButton, @DrawableRes int icon, Runnable onConfirmed){ + public static void showConfirmationAlert(Context context, @StringRes int title, @StringRes int message, @StringRes int confirmButton, @DrawableRes int icon, Runnable onConfirmed) { showConfirmationAlert(context, context.getString(title), context.getString(message), context.getString(confirmButton), icon, onConfirmed); } - public static void showConfirmationAlert(Context context, CharSequence title, CharSequence message, CharSequence confirmButton, int icon, Runnable onConfirmed){ + public static void showConfirmationAlert(Context context, CharSequence title, CharSequence message, CharSequence confirmButton, int icon, Runnable onConfirmed) { new M3AlertDialogBuilder(context) .setTitle(title) .setMessage(message) - .setPositiveButton(confirmButton, (dlg, i)->onConfirmed.run()) + .setPositiveButton(confirmButton, (dlg, i) -> onConfirmed.run()) .setNegativeButton(R.string.cancel, null) .setIcon(icon) .show(); } - public static void confirmToggleBlockUser(Activity activity, String accountID, Account account, boolean currentlyBlocked, Consumer resultCallback){ + 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.displayName), 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<>(){ + .setCallback(new Callback<>() { @Override - public void onSuccess(Relationship result){ + public void onSuccess(Relationship result) { + if (activity == null) return; resultCallback.accept(result); - if(!currentlyBlocked){ + if (!currentlyBlocked) { E.post(new RemoveAccountPostsEvent(accountID, account.id, false)); } } @Override - public void onError(ErrorResponse error){ + public void onError(ErrorResponse error) { error.showToast(activity); } }) @@ -388,54 +401,55 @@ public class UiUtils{ }); } - public static void confirmSoftBlockUser(Activity activity, String accountID, Account account, Consumer resultCallback){ + public static void confirmSoftBlockUser(Activity activity, String accountID, Account account, Consumer resultCallback) { showConfirmationAlert(activity, activity.getString(R.string.sk_remove_follower), activity.getString(R.string.sk_remove_follower_confirm, account.displayName), activity.getString(R.string.sk_do_remove_follower), R.drawable.ic_fluent_person_delete_24_regular, () -> new SetAccountBlocked(account.id, true).setCallback(new Callback<>() { - @Override - public void onSuccess(Relationship relationship) { - new SetAccountBlocked(account.id, false).setCallback(new Callback<>() { + @Override + public void onSuccess(Relationship relationship) { + new SetAccountBlocked(account.id, false).setCallback(new Callback<>() { + @Override + public void onSuccess(Relationship relationship) { + if (activity == null) return; + Toast.makeText(activity, R.string.sk_remove_follower_success, Toast.LENGTH_SHORT).show(); + resultCallback.accept(relationship); + } + + @Override + public void onError(ErrorResponse error) { + error.showToast(activity); + resultCallback.accept(relationship); + } + }).exec(accountID); + } + + @Override + public void onError(ErrorResponse error) { + error.showToast(activity); + } + }).exec(accountID) + ); + } + + public static void confirmToggleBlockDomain(Activity activity, String accountID, String domain, boolean currentlyBlocked, Runnable resultCallback) { + showConfirmationAlert(activity, activity.getString(currentlyBlocked ? R.string.confirm_unblock_domain_title : R.string.confirm_block_domain_title), + activity.getString(currentlyBlocked ? R.string.confirm_unblock : R.string.confirm_block, domain), + activity.getString(currentlyBlocked ? R.string.do_unblock : R.string.do_block), + R.drawable.ic_fluent_shield_28_regular, + () -> { + new SetDomainBlocked(domain, !currentlyBlocked) + .setCallback(new Callback<>() { @Override - public void onSuccess(Relationship relationship) { - Toast.makeText(activity, R.string.sk_remove_follower_success, Toast.LENGTH_SHORT).show(); - resultCallback.accept(relationship); + public void onSuccess(Object result) { + resultCallback.run(); } @Override public void onError(ErrorResponse error) { error.showToast(activity); - resultCallback.accept(relationship); - } - }).exec(accountID); - } - - @Override - public void onError(ErrorResponse error) { - error.showToast(activity); - } - }).exec(accountID) - ); - } - - public static void confirmToggleBlockDomain(Activity activity, String accountID, String domain, boolean currentlyBlocked, Runnable resultCallback){ - showConfirmationAlert(activity, activity.getString(currentlyBlocked ? R.string.confirm_unblock_domain_title : R.string.confirm_block_domain_title), - activity.getString(currentlyBlocked ? R.string.confirm_unblock : R.string.confirm_block, domain), - activity.getString(currentlyBlocked ? R.string.do_unblock : R.string.do_block), - R.drawable.ic_fluent_shield_28_regular, - ()->{ - new SetDomainBlocked(domain, !currentlyBlocked) - .setCallback(new Callback<>(){ - @Override - public void onSuccess(Object result){ - resultCallback.run(); - } - - @Override - public void onError(ErrorResponse error){ - error.showToast(activity); } }) .wrapProgress(activity, R.string.loading, false) @@ -443,24 +457,24 @@ public class UiUtils{ }); } - public static void confirmToggleMuteUser(Activity activity, String accountID, Account account, boolean currentlyMuted, Consumer resultCallback){ + public static void confirmToggleMuteUser(Activity activity, String accountID, Account account, boolean currentlyMuted, Consumer resultCallback) { showConfirmationAlert(activity, activity.getString(currentlyMuted ? R.string.confirm_unmute_title : R.string.confirm_mute_title), activity.getString(currentlyMuted ? R.string.confirm_unmute : R.string.confirm_mute, account.displayName), activity.getString(currentlyMuted ? R.string.do_unmute : R.string.do_mute), currentlyMuted ? R.drawable.ic_fluent_speaker_0_28_regular : R.drawable.ic_fluent_speaker_off_28_regular, - ()->{ + () -> { new SetAccountMuted(account.id, !currentlyMuted) - .setCallback(new Callback<>(){ + .setCallback(new Callback<>() { @Override - public void onSuccess(Relationship result){ + public void onSuccess(Relationship result) { resultCallback.accept(result); - if(!currentlyMuted){ + if (!currentlyMuted) { E.post(new RemoveAccountPostsEvent(accountID, account.id, false)); } } @Override - public void onError(ErrorResponse error){ + public void onError(ErrorResponse error) { error.showToast(activity); } }) @@ -468,27 +482,28 @@ public class UiUtils{ .exec(accountID); }); } - public static void confirmDeletePost(Activity activity, String accountID, Status status, Consumer resultCallback){ + + public static void confirmDeletePost(Activity activity, String accountID, Status status, Consumer resultCallback) { confirmDeletePost(activity, accountID, status, resultCallback, false); } - public static void confirmDeletePost(Activity activity, String accountID, Status status, Consumer resultCallback, boolean forRedraft){ + public static void confirmDeletePost(Activity activity, String accountID, Status status, Consumer resultCallback, boolean forRedraft) { showConfirmationAlert(activity, forRedraft ? R.string.sk_confirm_delete_and_redraft_title : R.string.confirm_delete_title, forRedraft ? R.string.sk_confirm_delete_and_redraft : R.string.confirm_delete, forRedraft ? R.string.sk_delete_and_redraft : R.string.delete, forRedraft ? R.drawable.ic_fluent_arrow_clockwise_28_regular : R.drawable.ic_fluent_delete_28_regular, () -> new DeleteStatus(status.id) - .setCallback(new Callback<>(){ + .setCallback(new Callback<>() { @Override - public void onSuccess(Status result){ + public void onSuccess(Status result) { resultCallback.accept(result); AccountSessionManager.getInstance().getAccount(accountID).getCacheController().deleteStatus(status.id); E.post(new StatusDeletedEvent(status.id, accountID)); } @Override - public void onError(ErrorResponse error){ + public void onError(ErrorResponse error) { error.showToast(activity); } }) @@ -497,7 +512,7 @@ public class UiUtils{ ); } - public static void confirmDeleteScheduledPost(Activity activity, String accountID, ScheduledStatus status, Runnable resultCallback){ + public static void confirmDeleteScheduledPost(Activity activity, String accountID, ScheduledStatus status, Runnable resultCallback) { boolean isDraft = status.scheduledAt.isAfter(CreateStatus.DRAFTS_AFTER_INSTANT); showConfirmationAlert(activity, isDraft ? R.string.sk_confirm_delete_draft_title : R.string.sk_confirm_delete_scheduled_post_title, @@ -505,15 +520,15 @@ public class UiUtils{ R.string.delete, R.drawable.ic_fluent_delete_28_regular, () -> new DeleteStatus.Scheduled(status.id) - .setCallback(new Callback<>(){ + .setCallback(new Callback<>() { @Override - public void onSuccess(Object nothing){ + public void onSuccess(Object o) { resultCallback.run(); E.post(new ScheduledStatusDeletedEvent(status.id, accountID)); } @Override - public void onError(ErrorResponse error){ + public void onError(ErrorResponse error) { error.showToast(activity); } }) @@ -522,13 +537,13 @@ public class UiUtils{ ); } - public static void confirmPinPost(Activity activity, String accountID, Status status, boolean pinned, Consumer resultCallback){ + public static void confirmPinPost(Activity activity, String accountID, Status status, boolean pinned, Consumer resultCallback) { showConfirmationAlert(activity, pinned ? R.string.sk_confirm_pin_post_title : R.string.sk_confirm_unpin_post_title, pinned ? R.string.sk_confirm_pin_post : R.string.sk_confirm_unpin_post, pinned ? R.string.sk_pin_post : R.string.sk_unpin_post, pinned ? R.drawable.ic_fluent_pin_28_regular : R.drawable.ic_fluent_pin_off_28_regular, - ()->{ + () -> { new SetStatusPinned(status.id, pinned) .setCallback(new Callback<>() { @Override @@ -591,12 +606,68 @@ public class UiUtils{ .exec(accountID)); } - public static void setRelationshipToActionButton(Relationship relationship, Button button){ + public static void setRelationshipToActionButton(Relationship relationship, Button button) { setRelationshipToActionButton(relationship, button, false); } - public static void setRelationshipToActionButton(Relationship relationship, Button button, boolean keepText){ + public static void setRelationshipToActionButton(Relationship relationship, Button button, boolean keepText) { CharSequence textBefore = keepText ? button.getText() : null; + boolean secondaryStyle; + if (relationship.blocking) { + button.setText(R.string.button_blocked); + secondaryStyle = true; + } else if (relationship.blockedBy) { + button.setText(R.string.button_follow); + secondaryStyle = false; + } else if (relationship.requested) { + button.setText(R.string.button_follow_pending); + secondaryStyle = true; + } else if (!relationship.following) { + button.setText(relationship.followedBy ? R.string.follow_back : R.string.button_follow); + secondaryStyle = false; + } else { + button.setText(R.string.button_following); + secondaryStyle = true; + } + + if (keepText) button.setText(textBefore); + + button.setEnabled(!relationship.blockedBy); + int attr = secondaryStyle ? R.attr.secondaryButtonStyle : android.R.attr.buttonStyle; + TypedArray ta = button.getContext().obtainStyledAttributes(new int[]{attr}); + int styleRes = ta.getResourceId(0, 0); + ta.recycle(); + 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}); + if (relationship.blocking) + button.setTextColor(button.getResources().getColorStateList(R.color.error_600)); + else + button.setTextColor(ta.getColorStateList(0)); + ta.recycle(); + } + + public static void performToggleAccountNotifications(Activity activity, Account account, String accountID, Relationship relationship, Button button, Consumer progressCallback, Consumer resultCallback) { + progressCallback.accept(true); + new SetAccountFollowed(account.id, true, relationship.showingReblogs, !relationship.notifying) + .setCallback(new Callback<>() { + @Override + public void onSuccess(Relationship result) { + resultCallback.accept(result); + progressCallback.accept(false); + Toast.makeText(activity, activity.getString(result.notifying ? R.string.sk_user_post_notifications_on : R.string.sk_user_post_notifications_off, '@' + account.username), Toast.LENGTH_SHORT).show(); + } + + @Override + public void onError(ErrorResponse error) { + progressCallback.accept(false); + error.showToast(activity); + } + }).exec(accountID); + } + + public static void setRelationshipToActionButtonM3(Relationship relationship, Button button){ boolean secondaryStyle; if(relationship.blocking){ button.setText(R.string.button_blocked); @@ -615,14 +686,9 @@ public class UiUtils{ secondaryStyle=true; } - if (keepText) button.setText(textBefore); - button.setEnabled(!relationship.blockedBy); - int attr=secondaryStyle ? R.attr.secondaryButtonStyle : android.R.attr.buttonStyle; - TypedArray ta=button.getContext().obtainStyledAttributes(new int[]{attr}); - int styleRes=ta.getResourceId(0, 0); - ta.recycle(); - ta=button.getContext().obtainStyledAttributes(styleRes, new int[]{android.R.attr.background}); + int styleRes=secondaryStyle ? R.style.Widget_Mastodon_M3_Button_Tonal : R.style.Widget_Mastodon_M3_Button_Filled; + 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}); @@ -633,45 +699,26 @@ public class UiUtils{ ta.recycle(); } - public static void performToggleAccountNotifications(Activity activity, Account account, String accountID, Relationship relationship, Button button, Consumer progressCallback, Consumer resultCallback) { - progressCallback.accept(true); - new SetAccountFollowed(account.id, true, relationship.showingReblogs, !relationship.notifying) - .setCallback(new Callback<>() { - @Override - public void onSuccess(Relationship result) { - resultCallback.accept(result); - progressCallback.accept(false); - Toast.makeText(activity, activity.getString(result.notifying ? R.string.sk_user_post_notifications_on : R.string.sk_user_post_notifications_off, '@'+account.username), Toast.LENGTH_SHORT).show(); - } - - @Override - public void onError(ErrorResponse error) { - progressCallback.accept(false); - error.showToast(activity); - } - }).exec(accountID); - } - - public static void performAccountAction(Activity activity, Account account, String accountID, Relationship relationship, Button button, Consumer progressCallback, Consumer resultCallback){ - if(relationship.blocking){ + public static void performAccountAction(Activity activity, Account account, String accountID, Relationship relationship, Button button, Consumer progressCallback, Consumer resultCallback) { + if (relationship.blocking) { confirmToggleBlockUser(activity, accountID, account, true, resultCallback); - }else if(relationship.muting){ + } else if (relationship.muting) { confirmToggleMuteUser(activity, accountID, account, true, resultCallback); - }else{ + } else { progressCallback.accept(true); new SetAccountFollowed(account.id, !relationship.following && !relationship.requested, true, false) - .setCallback(new Callback<>(){ + .setCallback(new Callback<>() { @Override - public void onSuccess(Relationship result){ + public void onSuccess(Relationship result) { resultCallback.accept(result); progressCallback.accept(false); - if(!result.following){ + if (!result.following) { E.post(new RemoveAccountPostsEvent(accountID, account.id, true)); } } @Override - public void onError(ErrorResponse error){ + public void onError(ErrorResponse error) { error.showToast(activity); progressCallback.accept(false); } @@ -701,7 +748,8 @@ public class UiUtils{ @Override public void onSuccess(Relationship rel) { E.post(new FollowRequestHandledEvent(accountID, false, account, rel)); - if (notificationID != null) E.post(new NotificationDeletedEvent(notificationID)); + if (notificationID != null) + E.post(new NotificationDeletedEvent(notificationID)); resultCallback.accept(rel); } @@ -714,34 +762,34 @@ public class UiUtils{ } } - public static void updateList(List oldList, List newList, RecyclerView list, RecyclerView.Adapter adapter, BiPredicate areItemsSame){ + public static void updateList(List oldList, List newList, RecyclerView list, RecyclerView.Adapter adapter, BiPredicate areItemsSame) { // Save topmost item position and offset because for some reason RecyclerView would scroll the list to weird places when you insert items at the top int topItem, topItemOffset; - if(list.getChildCount()==0){ - topItem=topItemOffset=0; - }else{ - View child=list.getChildAt(0); - topItem=list.getChildAdapterPosition(child); - topItemOffset=child.getTop(); + if (list.getChildCount() == 0) { + topItem = topItemOffset = 0; + } else { + View child = list.getChildAt(0); + topItem = list.getChildAdapterPosition(child); + topItemOffset = child.getTop(); } - DiffUtil.calculateDiff(new DiffUtil.Callback(){ + DiffUtil.calculateDiff(new DiffUtil.Callback() { @Override - public int getOldListSize(){ + public int getOldListSize() { return oldList.size(); } @Override - public int getNewListSize(){ + public int getNewListSize() { return newList.size(); } @Override - public boolean areItemsTheSame(int oldItemPosition, int newItemPosition){ + public boolean areItemsTheSame(int oldItemPosition, int newItemPosition) { return areItemsSame.test(oldList.get(oldItemPosition), newList.get(newItemPosition)); } @Override - public boolean areContentsTheSame(int oldItemPosition, int newItemPosition){ + public boolean areContentsTheSame(int oldItemPosition, int newItemPosition) { return true; } }).dispatchUpdatesTo(adapter); @@ -749,66 +797,80 @@ public class UiUtils{ list.scrollBy(0, topItemOffset); } - public static Bitmap getBitmapFromDrawable(Drawable d){ - if(d instanceof BitmapDrawable) + public static Bitmap getBitmapFromDrawable(Drawable d) { + if (d instanceof BitmapDrawable) return ((BitmapDrawable) d).getBitmap(); - Bitmap bitmap=Bitmap.createBitmap(d.getIntrinsicWidth(), d.getIntrinsicHeight(), Bitmap.Config.ARGB_8888); + Bitmap bitmap = Bitmap.createBitmap(d.getIntrinsicWidth(), d.getIntrinsicHeight(), Bitmap.Config.ARGB_8888); d.setBounds(0, 0, d.getIntrinsicWidth(), d.getIntrinsicHeight()); d.draw(new Canvas(bitmap)); return bitmap; } public static void insetPopupMenuIcon(Context context, MenuItem item) { - ColorStateList iconTint=ColorStateList.valueOf(UiUtils.getThemeColor(context, android.R.attr.textColorSecondary)); + ColorStateList iconTint = ColorStateList.valueOf(UiUtils.getThemeColor(context, android.R.attr.textColorSecondary)); insetPopupMenuIcon(item, iconTint); } + public static void insetPopupMenuIcon(MenuItem item, ColorStateList iconTint) { - Drawable icon=item.getIcon().mutate(); - if(Build.VERSION.SDK_INT>=26) item.setIconTintList(iconTint); + Drawable icon = item.getIcon().mutate(); + if (Build.VERSION.SDK_INT >= 26) item.setIconTintList(iconTint); else icon.setTintList(iconTint); - icon=new InsetDrawable(icon, V.dp(8), 0, V.dp(8), 0); + icon = new InsetDrawable(icon, V.dp(8), 0, V.dp(8), 0); item.setIcon(icon); - SpannableStringBuilder ssb=new SpannableStringBuilder(item.getTitle()); + SpannableStringBuilder ssb = new SpannableStringBuilder(item.getTitle()); item.setTitle(ssb); } + public static void resetPopupItemTint(MenuItem item) { + if (Build.VERSION.SDK_INT >= 26) { + item.setIconTintList(null); + } else { + Drawable icon = item.getIcon().mutate(); + icon.setTintList(null); + item.setIcon(icon); + } + } + public static void enableOptionsMenuIcons(Context context, Menu menu, @IdRes int... asAction) { - if(menu.getClass().getSimpleName().equals("MenuBuilder")){ + if (menu.getClass().getSimpleName().equals("MenuBuilder")) { try { - Method m = menu.getClass().getDeclaredMethod( - "setOptionalIconsVisible", Boolean.TYPE); + Method m = menu.getClass().getDeclaredMethod("setOptionalIconsVisible", Boolean.TYPE); m.setAccessible(true); m.invoke(menu, true); enableMenuIcons(context, menu, asAction); + } catch (Exception ignored) { } - catch(Exception ignored){} } } public static void enableMenuIcons(Context context, Menu m, @IdRes int... exclude) { - ColorStateList iconTint=ColorStateList.valueOf(UiUtils.getThemeColor(context, android.R.attr.textColorSecondary)); - for(int i=0;i id == item.getItemId())) continue; + ColorStateList iconTint = ColorStateList.valueOf(UiUtils.getThemeColor(context, android.R.attr.textColorSecondary)); + for (int i = 0; i < m.size(); i++) { + MenuItem item = m.getItem(i); + SubMenu subMenu = item.getSubMenu(); + if (subMenu != null) enableMenuIcons(context, subMenu, exclude); + if (item.getIcon() == null || Arrays.stream(exclude).anyMatch(id -> id == item.getItemId())) + continue; insetPopupMenuIcon(item, iconTint); } } - public static void enablePopupMenuIcons(Context context, PopupMenu menu){ - Menu m=menu.getMenu(); - if(Build.VERSION.SDK_INT>=29){ + public static void enablePopupMenuIcons(Context context, PopupMenu menu) { + Menu m = menu.getMenu(); + if (Build.VERSION.SDK_INT >= 29) { menu.setForceShowIcon(true); - }else{ - try{ - Method setOptionalIconsVisible=m.getClass().getDeclaredMethod("setOptionalIconsVisible", boolean.class); + } else { + try { + Method setOptionalIconsVisible = m.getClass().getDeclaredMethod("setOptionalIconsVisible", boolean.class); setOptionalIconsVisible.setAccessible(true); setOptionalIconsVisible.invoke(m, true); - }catch(Exception ignore){} + } catch (Exception ignore) { + } } enableMenuIcons(context, m); } - public static void setUserPreferredTheme(Context context){ + public static void setUserPreferredTheme(Context context) { context.setTheme(switch (theme) { case LIGHT -> R.style.Theme_Mastodon_Light; case DARK -> trueBlackTheme ? R.style.Theme_Mastodon_Dark_TrueBlack : R.style.Theme_Mastodon_Dark; @@ -818,10 +880,11 @@ public class UiUtils{ ColorPalette palette = ColorPalette.palettes.get(GlobalUserPreferences.color); if (palette != null) palette.apply(context); } - public static boolean isDarkTheme(){ - if(theme==GlobalUserPreferences.ThemePreference.AUTO) - return (MastodonApp.context.getResources().getConfiguration().uiMode & Configuration.UI_MODE_NIGHT_MASK)==Configuration.UI_MODE_NIGHT_YES; - return theme==GlobalUserPreferences.ThemePreference.DARK; + + public static boolean isDarkTheme() { + if (theme == GlobalUserPreferences.ThemePreference.AUTO) + return (MastodonApp.context.getResources().getConfiguration().uiMode & Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES; + return theme == GlobalUserPreferences.ThemePreference.DARK; } // https://mastodon.foo.bar/@User @@ -848,7 +911,8 @@ public class UiUtils{ return false; } - if (uri.getQuery() != null || uri.getFragment() != null || uri.getPath() == null) return false; + if (uri.getQuery() != null || uri.getFragment() != null || uri.getPath() == null) + return false; String it = uri.getPath(); return it.matches("^/@[^/]+$") || @@ -873,8 +937,8 @@ public class UiUtils{ } public static void pickAccount(Context context, String exceptFor, @StringRes int titleRes, @DrawableRes int iconRes, Consumer sessionConsumer, Consumer transformDialog) { - List sessions=AccountSessionManager.getInstance().getLoggedInAccounts() - .stream().filter(s->!s.getID().equals(exceptFor)).collect(Collectors.toList()); + List sessions = AccountSessionManager.getInstance().getLoggedInAccounts() + .stream().filter(s -> !s.getID().equals(exceptFor)).collect(Collectors.toList()); AlertDialog.Builder builder = new M3AlertDialogBuilder(context) .setItems( @@ -887,6 +951,35 @@ public class UiUtils{ builder.show(); } + public static void restartApp() { + Intent intent = Intent.makeRestartActivityTask(MastodonApp.context.getPackageManager().getLaunchIntentForPackage(MastodonApp.context.getPackageName()).getComponent()); + MastodonApp.context.startActivity(intent); + Runtime.getRuntime().exit(0); + } + + public static MenuItem makeBackItem(Menu m) { + MenuItem back = m.add(0, R.id.menu_back, NONE, R.string.back); + back.setIcon(R.drawable.ic_fluent_arrow_left_24_regular); + return back; + } + + public static boolean setExtraTextInfo(Context ctx, TextView extraText, StatusPrivacy visibility, boolean localOnly) { + List extraParts = new ArrayList<>(); + if (localOnly || (visibility != null && visibility.equals(StatusPrivacy.LOCAL))) + extraParts.add(ctx.getString(R.string.sk_inline_local_only)); + if (visibility != null && visibility.equals(StatusPrivacy.DIRECT)) + extraParts.add(ctx.getString(R.string.sk_inline_direct)); + if (!extraParts.isEmpty()) { + String sep = ctx.getString(R.string.sk_separator); + extraText.setText(String.join(" " + sep + " ", extraParts)); + extraText.setVisibility(View.VISIBLE); + return true; + } else { + extraText.setVisibility(View.GONE); + return false; + } + } + @FunctionalInterface public interface InteractionPerformer { void interact(StatusInteractionController ic, Status status, Consumer resultConsumer); @@ -917,18 +1010,19 @@ public class UiUtils{ } new GetSearchResults(queryStatus.url, GetSearchResults.Type.STATUSES, true).setCallback(new Callback<>() { - @Override - public void onSuccess(SearchResults results) { - if (!results.statuses.isEmpty()) statusConsumer.accept(results.statuses.get(0)); - else Toast.makeText(context, R.string.sk_resource_not_found, Toast.LENGTH_SHORT).show(); - } + @Override + public void onSuccess(SearchResults results) { + if (!results.statuses.isEmpty()) statusConsumer.accept(results.statuses.get(0)); + else + Toast.makeText(context, R.string.sk_resource_not_found, Toast.LENGTH_SHORT).show(); + } - @Override - public void onError(ErrorResponse error) { - error.showToast(context); - } - }) - .wrapProgress((Activity)context, R.string.loading, true, + @Override + public void onError(ErrorResponse error) { + error.showToast(context); + } + }) + .wrapProgress((Activity) context, R.string.loading, true, d -> transformDialogForLookup(context, targetAccountID, null, d)) .exec(targetAccountID); } @@ -952,28 +1046,28 @@ public class UiUtils{ } } - public static void openURL(Context context, String accountID, String url, boolean launchBrowser){ - Uri uri=Uri.parse(url); - List path=uri.getPathSegments(); - if(accountID!=null && "https".equals(uri.getScheme())){ - if(path.size()==2 && path.get(0).matches("^@[a-zA-Z0-9_]+$") && path.get(1).matches("^[0-9]+$") && AccountSessionManager.getInstance().getAccount(accountID).domain.equalsIgnoreCase(uri.getAuthority())){ + public static void openURL(Context context, String accountID, String url, boolean launchBrowser) { + Uri uri = Uri.parse(url); + List path = uri.getPathSegments(); + if (accountID != null && "https".equals(uri.getScheme())) { + if (path.size() == 2 && path.get(0).matches("^@[a-zA-Z0-9_]+$") && path.get(1).matches("^[0-9]+$") && AccountSessionManager.getInstance().getAccount(accountID).domain.equalsIgnoreCase(uri.getAuthority())) { new GetStatusByID(path.get(1)) - .setCallback(new Callback<>(){ + .setCallback(new Callback<>() { @Override - public void onSuccess(Status result){ - Bundle args=new Bundle(); + public void onSuccess(Status result) { + Bundle args = new Bundle(); args.putString("account", accountID); args.putParcelable("status", Parcels.wrap(result)); Nav.go((Activity) context, ThreadFragment.class, args); } @Override - public void onError(ErrorResponse error){ + public void onError(ErrorResponse error) { error.showToast(context); if (launchBrowser) launchWebBrowser(context, url); } }) - .wrapProgress((Activity)context, R.string.loading, true, + .wrapProgress((Activity) context, R.string.loading, true, d -> transformDialogForLookup(context, accountID, url, d)) .exec(accountID); return; @@ -982,7 +1076,7 @@ public class UiUtils{ .setCallback(new Callback<>() { @Override public void onSuccess(SearchResults results) { - Bundle args=new Bundle(); + Bundle args = new Bundle(); args.putString("account", accountID); if (!results.statuses.isEmpty()) { args.putParcelable("status", Parcels.wrap(results.statuses.get(0))); @@ -992,7 +1086,8 @@ public class UiUtils{ Nav.go((Activity) context, ProfileFragment.class, args); } else { if (launchBrowser) launchWebBrowser(context, url); - else Toast.makeText(context, R.string.sk_resource_not_found, Toast.LENGTH_SHORT).show(); + else + Toast.makeText(context, R.string.sk_resource_not_found, Toast.LENGTH_SHORT).show(); } } @@ -1002,7 +1097,7 @@ public class UiUtils{ if (launchBrowser) launchWebBrowser(context, url); } }) - .wrapProgress((Activity)context, R.string.loading, true, + .wrapProgress((Activity) context, R.string.loading, true, d -> transformDialogForLookup(context, accountID, url, d)) .exec(accountID); return; @@ -1014,36 +1109,45 @@ public class UiUtils{ public static void copyText(View v, String text) { Context context = v.getContext(); context.getSystemService(ClipboardManager.class).setPrimaryClip(ClipData.newPlainText(null, text)); - if(Build.VERSION.SDK_INT props=Class.forName("android.os.SystemProperties"); - Method get=props.getMethod("get", String.class); - return (String)get.invoke(null, key); - }catch(Exception ignore){} + private static String getSystemProperty(String key) { + try { + Class props = Class.forName("android.os.SystemProperties"); + Method get = props.getMethod("get", String.class); + return (String) get.invoke(null, key); + } catch (Exception ignore) { + } return null; } - public static boolean isMIUI(){ + public static boolean isMIUI() { return !TextUtils.isEmpty(getSystemProperty("ro.miui.ui.version.code")); } - public static boolean pickAccountForCompose(Activity activity, String accountID, String prefilledText){ + public static int alphaBlendColors(int color1, int color2, float alpha) { + float alpha0 = 1f - alpha; + int r = Math.round(((color1 >> 16) & 0xFF) * alpha0 + ((color2 >> 16) & 0xFF) * alpha); + int g = Math.round(((color1 >> 8) & 0xFF) * alpha0 + ((color2 >> 8) & 0xFF) * alpha); + int b = Math.round((color1 & 0xFF) * alpha0 + (color2 & 0xFF) * alpha); + return 0xFF000000 | (r << 16) | (g << 8) | b; + } + + public static boolean pickAccountForCompose(Activity activity, String accountID, String prefilledText) { Bundle args = new Bundle(); if (prefilledText != null) args.putString("prefilledText", prefilledText); return pickAccountForCompose(activity, accountID, args); } - public static boolean pickAccountForCompose(Activity activity, String accountID){ + public static boolean pickAccountForCompose(Activity activity, String accountID) { return pickAccountForCompose(activity, accountID, (String) null); } - public static boolean pickAccountForCompose(Activity activity, String accountID, Bundle args){ + public static boolean pickAccountForCompose(Activity activity, String accountID, Bundle args) { if (AccountSessionManager.getInstance().getLoggedInAccounts().size() > 1) { UiUtils.pickAccount(activity, accountID, 0, 0, session -> { args.putString("account", session.getID()); @@ -1069,4 +1173,101 @@ public class UiUtils{ Log.e("reduceSwipeSensitivity", Log.getStackTraceString(ex)); } } + + public static View makeOverflowActionView(Context ctx) { + // container needs tooltip, content description + LinearLayout container = new LinearLayout(ctx, null, 0, R.style.Widget_Mastodon_ActionButton_Overflow) { + @Override + public CharSequence getAccessibilityClassName() { + return Button.class.getName(); + } + }; + // image needs, well, the image, and the paddings + ImageView image = new ImageView(ctx, null, 0, R.style.Widget_Mastodon_ActionButton_Overflow); + + image.setDuplicateParentStateEnabled(true); + image.setImportantForAccessibility(View.IMPORTANT_FOR_ACCESSIBILITY_NO); + image.setClickable(false); + image.setFocusable(false); + image.setEnabled(false); + + // problem: as per overflow action button defaults, the padding on left and right is unequal + // so (however the native overflow button manages this), the ripple background is off-center + + // workaround: set both paddings to the smaller, left one… + int end = image.getPaddingEnd(); + int start = image.getPaddingStart(); + int paddingDiff = end - start; // what's missing to the long padding + image.setPaddingRelative(start, image.getPaddingTop(), start, image.getPaddingBottom()); + + // …and add the missing padding to the right on the container + container.setPaddingRelative(0, 0, paddingDiff, 0); + container.setBackground(null); + container.setClickable(true); + container.setFocusable(true); + + container.addView(image); + + // fucking finally + return container; + } + + /** + * Check to see if Android platform photopicker is available on the device\ + * + * @return whether the device supports photopicker intents. + */ + @SuppressLint("NewApi") + public static boolean isPhotoPickerAvailable(){ + if(Build.VERSION.SDK_INT>=Build.VERSION_CODES.TIRAMISU){ + return true; + }else if(Build.VERSION.SDK_INT>=Build.VERSION_CODES.R){ + return SdkExtensions.getExtensionVersion(Build.VERSION_CODES.R)>=2; + }else + return false; + } + + @SuppressLint("InlinedApi") + public static Intent getMediaPickerIntent(String[] mimeTypes, int maxCount){ + Intent intent; + if(isPhotoPickerAvailable()){ + intent=new Intent(MediaStore.ACTION_PICK_IMAGES); + if(maxCount>1) + intent.putExtra(MediaStore.EXTRA_PICK_IMAGES_MAX, maxCount); + }else{ + intent=new Intent(Intent.ACTION_GET_CONTENT); + intent.addCategory(Intent.CATEGORY_OPENABLE); + } + if(mimeTypes.length>1){ + intent.setType("*/*"); + intent.putExtra(Intent.EXTRA_MIME_TYPES, mimeTypes); + }else if(mimeTypes.length==1){ + intent.setType(mimeTypes[0]); + }else{ + intent.setType("*/*"); + } + if(maxCount>1) + intent.putExtra(Intent.EXTRA_ALLOW_MULTIPLE, true); + return intent; + } + + /** + * Wraps a View.OnClickListener to filter multiple clicks in succession. + * Useful for buttons that perform some action that changes their state asynchronously. + * @param l + * @return + */ + public static View.OnClickListener rateLimitedClickListener(View.OnClickListener l){ + return new View.OnClickListener(){ + private long lastClickTime; + + @Override + public void onClick(View v){ + if(SystemClock.uptimeMillis()-lastClickTime>500L){ + lastClickTime=SystemClock.uptimeMillis(); + l.onClick(v); + } + } + }; + } } diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/views/FloatingHintEditTextLayout.java b/mastodon/src/main/java/org/joinmastodon/android/ui/views/FloatingHintEditTextLayout.java index bb92feeef..e5fb7292c 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/ui/views/FloatingHintEditTextLayout.java +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/views/FloatingHintEditTextLayout.java @@ -20,6 +20,7 @@ import android.text.Editable; import android.util.AttributeSet; import android.util.TypedValue; import android.view.Gravity; +import android.view.View; import android.view.ViewGroup; import android.view.ViewTreeObserver; import android.widget.EditText; @@ -47,6 +48,7 @@ public class FloatingHintEditTextLayout extends FrameLayout{ private RectF tmpRect=new RectF(); private ColorStateList labelColors, origHintColors; private boolean errorState; + private TextView errorView; public FloatingHintEditTextLayout(Context context){ this(context, null); @@ -95,12 +97,22 @@ public class FloatingHintEditTextLayout extends FrameLayout{ label.setAlpha(0f); edit.addTextChangedListener(new SimpleTextWatcher(this::onTextChanged)); + + errorView=new LinkedTextView(getContext()); + errorView.setTextAppearance(R.style.m3_body_small); + errorView.setTextColor(UiUtils.getThemeColor(getContext(), R.attr.colorM3OnSurfaceVariant)); + errorView.setLinkTextColor(UiUtils.getThemeColor(getContext(), R.attr.colorM3Primary)); + errorView.setLayoutParams(new LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT)); + errorView.setPadding(V.dp(16), V.dp(4), V.dp(16), 0); + errorView.setVisibility(View.GONE); + addView(errorView); } private void onTextChanged(Editable text){ if(errorState){ + errorView.setVisibility(View.GONE); errorState=false; - setForeground(getResources().getDrawable(R.drawable.bg_m3_outlined_text_field)); + setForeground(getResources().getDrawable(R.drawable.bg_m3_outlined_text_field, getContext().getTheme())); refreshDrawableState(); } boolean newHintVisible=text.length()==0; @@ -211,12 +223,34 @@ public class FloatingHintEditTextLayout extends FrameLayout{ label.setTextColor(color.getColorForState(getDrawableState(), 0xff00ff00)); } - public void setErrorState(){ + public void setErrorState(CharSequence error){ if(errorState) return; errorState=true; setForeground(getResources().getDrawable(R.drawable.bg_m3_outlined_text_field_error, getContext().getTheme())); label.setTextColor(UiUtils.getThemeColor(getContext(), R.attr.colorM3Error)); + errorView.setVisibility(VISIBLE); + errorView.setText(error); + } + + @Override + protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec){ + if(errorView.getVisibility()!=GONE){ + int width=MeasureSpec.getSize(widthMeasureSpec)-getPaddingLeft()-getPaddingRight(); + LayoutParams editLP=(LayoutParams) edit.getLayoutParams(); + width-=editLP.leftMargin+editLP.rightMargin; + errorView.measure(width | MeasureSpec.EXACTLY, MeasureSpec.UNSPECIFIED); + LayoutParams lp=(LayoutParams) errorView.getLayoutParams(); + lp.width=width; + lp.height=errorView.getMeasuredHeight(); + lp.gravity=Gravity.LEFT | Gravity.BOTTOM; + lp.leftMargin=editLP.leftMargin; + editLP.bottomMargin=errorView.getMeasuredHeight(); + }else{ + LayoutParams editLP=(LayoutParams) edit.getLayoutParams(); + editLP.bottomMargin=0; + } + super.onMeasure(widthMeasureSpec, heightMeasureSpec); } private class PaddedForegroundDrawable extends Drawable{ @@ -313,8 +347,8 @@ public class FloatingHintEditTextLayout extends FrameLayout{ @Override protected void onBoundsChange(@NonNull Rect bounds){ super.onBoundsChange(bounds); - LayoutParams lp=(LayoutParams) edit.getLayoutParams(); - wrapped.setBounds(bounds.left+lp.leftMargin-V.dp(12), bounds.top, bounds.right-lp.rightMargin+V.dp(12), bounds.bottom); + int offset=V.dp(12); + wrapped.setBounds(edit.getLeft()-offset, edit.getTop()-offset, edit.getRight()+offset, edit.getBottom()+offset); } } } diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/views/ListTimelineEditor.java b/mastodon/src/main/java/org/joinmastodon/android/ui/views/ListTimelineEditor.java index d1f2fbf06..e4438199e 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/ui/views/ListTimelineEditor.java +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/views/ListTimelineEditor.java @@ -10,6 +10,9 @@ import android.widget.Button; import android.widget.LinearLayout; import android.widget.PopupMenu; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + import org.joinmastodon.android.R; import org.joinmastodon.android.model.ListTimeline; @@ -37,9 +40,9 @@ public class ListTimelineEditor extends LinearLayout { setRepliesPolicy(ListTimeline.RepliesPolicy.LIST); } - public void applyList(String title, ListTimeline.RepliesPolicy policy) { + public void applyList(String title, @Nullable ListTimeline.RepliesPolicy policy) { input.getEditText().setText(title); - setRepliesPolicy(policy); + if (policy != null) setRepliesPolicy(policy); } public String getTitle() { @@ -50,7 +53,7 @@ public class ListTimelineEditor extends LinearLayout { return policy; } - public void setRepliesPolicy(ListTimeline.RepliesPolicy policy) { + public void setRepliesPolicy(@NonNull ListTimeline.RepliesPolicy policy) { this.policy = policy; switch (policy) { case FOLLOWED -> button.setText(R.string.sk_list_replies_policy_followed); diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/views/NestableScrollView.java b/mastodon/src/main/java/org/joinmastodon/android/ui/views/NestableScrollView.java new file mode 100644 index 000000000..7d5ffd60d --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/views/NestableScrollView.java @@ -0,0 +1,58 @@ +package org.joinmastodon.android.ui.views; + +import android.content.Context; +import android.util.AttributeSet; +import android.util.Log; +import android.view.MotionEvent; +import android.view.ViewConfiguration; +import android.widget.ScrollView; + +public class NestableScrollView extends ScrollView{ + private float downY, touchslop; + private boolean didDisallow; + + public NestableScrollView(Context context){ + super(context); + init(); + } + + public NestableScrollView(Context context, AttributeSet attrs){ + super(context, attrs); + init(); + } + + public NestableScrollView(Context context, AttributeSet attrs, int defStyleAttr){ + super(context, attrs, defStyleAttr); + init(); + } + + public NestableScrollView(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes){ + super(context, attrs, defStyleAttr, defStyleRes); + init(); + } + + private void init(){ + touchslop=ViewConfiguration.get(getContext()).getScaledTouchSlop(); + } + + @Override + public boolean onTouchEvent(MotionEvent ev){ + if(ev.getAction()==MotionEvent.ACTION_DOWN){ + if(canScrollVertically(-1) || canScrollVertically(1)){ + getParent().requestDisallowInterceptTouchEvent(true); + didDisallow=true; + }else{ + didDisallow=false; + } + downY=ev.getY(); + }else if(didDisallow && ev.getAction()==MotionEvent.ACTION_MOVE){ + if(Math.abs(downY-ev.getY())>=touchslop){ + if(!canScrollVertically((int)(downY-ev.getY()))){ + didDisallow=false; + getParent().requestDisallowInterceptTouchEvent(false); + } + } + } + return super.onTouchEvent(ev); + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/views/UntouchableScrollView.java b/mastodon/src/main/java/org/joinmastodon/android/ui/views/UntouchableScrollView.java new file mode 100644 index 000000000..6b228cdde --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/views/UntouchableScrollView.java @@ -0,0 +1,30 @@ +package org.joinmastodon.android.ui.views; + +import android.content.Context; +import android.util.AttributeSet; +import android.view.MotionEvent; +import android.widget.ScrollView; + +public class UntouchableScrollView extends ScrollView { + public UntouchableScrollView(Context context) { + super(context); + } + + public UntouchableScrollView(Context context, AttributeSet attrs) { + super(context, attrs); + } + + public UntouchableScrollView(Context context, AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + } + + public UntouchableScrollView(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) { + super(context, attrs, defStyleAttr, defStyleRes); + } + + @Override + public boolean onTouchEvent(MotionEvent event) { + super.onTouchEvent(event); + return false; + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/utils/ElevationOnScrollListener.java b/mastodon/src/main/java/org/joinmastodon/android/utils/ElevationOnScrollListener.java new file mode 100644 index 000000000..b59425085 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/utils/ElevationOnScrollListener.java @@ -0,0 +1,116 @@ +package org.joinmastodon.android.utils; + +import android.animation.Animator; +import android.animation.AnimatorListenerAdapter; +import android.animation.AnimatorSet; +import android.animation.ObjectAnimator; +import android.content.Context; +import android.graphics.Color; +import android.graphics.drawable.Drawable; +import android.graphics.drawable.LayerDrawable; +import android.view.View; + +import org.joinmastodon.android.R; +import org.joinmastodon.android.ui.utils.UiUtils; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +import androidx.annotation.NonNull; +import androidx.recyclerview.widget.RecyclerView; +import me.grishka.appkit.utils.CubicBezierInterpolator; +import me.grishka.appkit.utils.V; +import me.grishka.appkit.views.FragmentRootLinearLayout; + +public class ElevationOnScrollListener extends RecyclerView.OnScrollListener implements View.OnScrollChangeListener{ + private boolean isAtTop; + private Animator currentPanelsAnim; + private View[] views; + private FragmentRootLinearLayout fragmentRootLayout; + + public ElevationOnScrollListener(FragmentRootLinearLayout fragmentRootLayout, View... views){ + isAtTop=true; + this.fragmentRootLayout=fragmentRootLayout; + this.views=views; + for(View v:views){ + Drawable bg=v.getBackground().mutate(); + v.setBackground(bg); + if(bg instanceof LayerDrawable ld){ + Drawable overlay=ld.findDrawableByLayerId(R.id.color_overlay); + if(overlay!=null){ + overlay.setAlpha(0); + } + } + } + } + + public void setViews(View... views){ + List oldViews=Arrays.asList(this.views); + this.views=views; + for(View v:views){ + if(oldViews.contains(v)) + continue; + Drawable bg=v.getBackground().mutate(); + v.setBackground(bg); + if(bg instanceof LayerDrawable ld){ + Drawable overlay=ld.findDrawableByLayerId(R.id.color_overlay); + if(overlay!=null){ + overlay.setAlpha(isAtTop ? 0 : 20); + } + } + v.setTranslationZ(isAtTop ? 0 : V.dp(3)); + } + } + + @Override + public void onScrolled(@NonNull RecyclerView recyclerView, int dx, int dy){ + boolean newAtTop=recyclerView.getChildCount()==0 || (recyclerView.getChildAdapterPosition(recyclerView.getChildAt(0))==0 && recyclerView.getChildAt(0).getTop()==recyclerView.getPaddingTop()); + handleScroll(recyclerView.getContext(), newAtTop); + } + + @Override + public void onScrollChange(View v, int scrollX, int scrollY, int oldScrollX, int oldScrollY){ + handleScroll(v.getContext(), scrollY<=0); + } + + private void handleScroll(Context context, boolean newAtTop){ + if(newAtTop!=isAtTop){ + isAtTop=newAtTop; + if(currentPanelsAnim!=null) + currentPanelsAnim.cancel(); + + AnimatorSet set=new AnimatorSet(); + ArrayList anims=new ArrayList<>(); + for(View v:views){ + if(v.getBackground() instanceof LayerDrawable ld){ + Drawable overlay=ld.findDrawableByLayerId(R.id.color_overlay); + if(overlay!=null){ + anims.add(ObjectAnimator.ofInt(overlay, "alpha", isAtTop ? 0 : 20)); + } + } + anims.add(ObjectAnimator.ofFloat(v, View.TRANSLATION_Z, isAtTop ? 0 : V.dp(3))); + } + if(fragmentRootLayout!=null){ + int color; + if(isAtTop){ + color=UiUtils.getThemeColor(context, R.attr.toolbarBackground); + }else{ + color=UiUtils.alphaBlendColors(UiUtils.getThemeColor(context, R.attr.toolbarBackground), UiUtils.getThemeColor(context, R.attr.colorWindowBackground), 0.07843137f); + } + anims.add(ObjectAnimator.ofArgb(fragmentRootLayout, "statusBarColor", color)); + } + set.playTogether(anims); + set.setDuration(150); + set.setInterpolator(CubicBezierInterpolator.DEFAULT); + set.addListener(new AnimatorListenerAdapter(){ + @Override + public void onAnimationEnd(Animator animation){ + currentPanelsAnim=null; + } + }); + set.start(); + currentPanelsAnim=set; + } + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/utils/MastodonLanguage.java b/mastodon/src/main/java/org/joinmastodon/android/utils/MastodonLanguage.java index 2e4eb949a..a184032d9 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/utils/MastodonLanguage.java +++ b/mastodon/src/main/java/org/joinmastodon/android/utils/MastodonLanguage.java @@ -7,6 +7,7 @@ import android.content.res.Resources; import android.os.Build; import android.os.LocaleList; +import org.joinmastodon.android.GlobalUserPreferences; import org.joinmastodon.android.model.Instance; import java.util.ArrayList; diff --git a/mastodon/src/main/java/org/joinmastodon/android/utils/StatusFilterPredicate.java b/mastodon/src/main/java/org/joinmastodon/android/utils/StatusFilterPredicate.java index 4555cdfdb..9e4b91413 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/utils/StatusFilterPredicate.java +++ b/mastodon/src/main/java/org/joinmastodon/android/utils/StatusFilterPredicate.java @@ -38,4 +38,22 @@ public class StatusFilterPredicate implements Predicate{ } return true; } + + public boolean testWithWarning(Status status) { + if(status.filtered!=null){ + if (status.filtered.isEmpty()){ + return true; + } + boolean matches=status.filtered.stream() + .map(filterResult->filterResult.filter) + .filter(filter->filter.expiresAt==null||filter.expiresAt.isAfter(Instant.now())) + .anyMatch(filter->filter.filterAction==Filter.FilterAction.WARN); + return !matches; + } + for(Filter filter:filters){ + if(filter.matches(status)) + return false; + } + return true; + } } diff --git a/mastodon/src/main/java/org/joinmastodon/android/utils/StatusTextEncoder.java b/mastodon/src/main/java/org/joinmastodon/android/utils/StatusTextEncoder.java new file mode 100644 index 000000000..12f3d4891 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/utils/StatusTextEncoder.java @@ -0,0 +1,58 @@ +package org.joinmastodon.android.utils; + +import android.text.TextUtils; + +import org.joinmastodon.android.fragments.ComposeFragment; + +import java.util.function.Function; +import java.util.regex.MatchResult; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +// not a good class +public class StatusTextEncoder { + private final Function fn; + + // see ComposeFragment.HIGHLIGHT_PATTERN + private final static Pattern EXCLUDE_PATTERN = Pattern.compile("\\s*(?:@([a-zA-Z0-9_]+)(@[a-zA-Z0-9_.-]+)?|#([^\\s.]+))\\s*"); + + public StatusTextEncoder(Function fn) { + this.fn = fn; + } + + // prettiest method award winner 2023 [citation needed] + public String encode(String content) { + StringBuilder encodedString = new StringBuilder(); + // matches mentions and hashtags + Matcher m = EXCLUDE_PATTERN.matcher(content); + int previousEnd = 0; + while (m.find()) { + MatchResult res = m.toMatchResult(); + // everything before the match - do encode + encodedString.append(fn.apply(content.substring(previousEnd, res.start()))); + previousEnd = res.end(); + // the match - do not encode + encodedString.append(res.group()); + } + // everything after the last match - do encode + encodedString.append(fn.apply(content.substring(previousEnd))); + return encodedString.toString(); + } + + // prettiest almost-exact replica of a pretty function + public String decode(String content, Pattern regex) { + Matcher m = regex.matcher(content); + StringBuilder decodedString = new StringBuilder(); + int previousEnd = 0; + while (m.find()) { + MatchResult res = m.toMatchResult(); + // everything before the match - do not decode + decodedString.append(content.substring(previousEnd, res.start())); + previousEnd = res.end(); + // the match - do decode + decodedString.append(fn.apply(res.group())); + } + decodedString.append(content.substring(previousEnd)); + return decodedString.toString(); + } +} diff --git a/mastodon/src/main/res/color/button_text_m3_tonal.xml b/mastodon/src/main/res/color/button_text_m3_tonal.xml new file mode 100644 index 000000000..cce583679 --- /dev/null +++ b/mastodon/src/main/res/color/button_text_m3_tonal.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/mastodon/src/main/res/drawable/bg_button_m3_tonal.xml b/mastodon/src/main/res/drawable/bg_button_m3_tonal.xml new file mode 100644 index 000000000..d6043fc37 --- /dev/null +++ b/mastodon/src/main/res/drawable/bg_button_m3_tonal.xml @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/mastodon/src/main/res/drawable/bg_image_alt_overlay.xml b/mastodon/src/main/res/drawable/bg_image_alt_overlay.xml new file mode 100644 index 000000000..56dc6e393 --- /dev/null +++ b/mastodon/src/main/res/drawable/bg_image_alt_overlay.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/mastodon/src/main/res/drawable/bg_image_no_alt_overlay.xml b/mastodon/src/main/res/drawable/bg_image_no_alt_overlay.xml new file mode 100644 index 000000000..5cb6f3ada --- /dev/null +++ b/mastodon/src/main/res/drawable/bg_image_no_alt_overlay.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/mastodon/src/main/res/drawable/bg_onboarding_avatar.xml b/mastodon/src/main/res/drawable/bg_onboarding_avatar.xml new file mode 100644 index 000000000..924918801 --- /dev/null +++ b/mastodon/src/main/res/drawable/bg_onboarding_avatar.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/mastodon/src/main/res/drawable/bg_onboarding_panel.xml b/mastodon/src/main/res/drawable/bg_onboarding_panel.xml index a6b2c10ae..e959cc39c 100644 --- a/mastodon/src/main/res/drawable/bg_onboarding_panel.xml +++ b/mastodon/src/main/res/drawable/bg_onboarding_panel.xml @@ -1,9 +1,9 @@ - + - + \ No newline at end of file diff --git a/mastodon/src/main/res/drawable/bg_pill.xml b/mastodon/src/main/res/drawable/bg_pill.xml new file mode 100644 index 000000000..af6a69a4f --- /dev/null +++ b/mastodon/src/main/res/drawable/bg_pill.xml @@ -0,0 +1,8 @@ + + + + + + \ No newline at end of file diff --git a/mastodon/src/main/res/drawable/ic_add_24px.xml b/mastodon/src/main/res/drawable/ic_add_24px.xml new file mode 100644 index 000000000..b234bf84a --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_add_24px.xml @@ -0,0 +1,9 @@ + + + diff --git a/mastodon/src/main/res/drawable/ic_add_photo_alternate_48px.xml b/mastodon/src/main/res/drawable/ic_add_photo_alternate_48px.xml new file mode 100644 index 000000000..e7ffd70e0 --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_add_photo_alternate_48px.xml @@ -0,0 +1,9 @@ + + + diff --git a/mastodon/src/main/res/drawable/ic_baseline_close_24.xml b/mastodon/src/main/res/drawable/ic_baseline_close_24.xml new file mode 100644 index 000000000..844b6b62e --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_baseline_close_24.xml @@ -0,0 +1,5 @@ + + + diff --git a/mastodon/src/main/res/drawable/ic_drag_handle_24px.xml b/mastodon/src/main/res/drawable/ic_drag_handle_24px.xml new file mode 100644 index 000000000..7428dd8b4 --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_drag_handle_24px.xml @@ -0,0 +1,9 @@ + + + diff --git a/mastodon/src/main/res/drawable/ic_fluent_accessibility_24_regular.xml b/mastodon/src/main/res/drawable/ic_fluent_accessibility_24_regular.xml new file mode 100644 index 000000000..1e0603d2b --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_fluent_accessibility_24_regular.xml @@ -0,0 +1,3 @@ + + + diff --git a/mastodon/src/main/res/drawable/ic_fluent_add_24_regular.xml b/mastodon/src/main/res/drawable/ic_fluent_add_24_regular.xml new file mode 100644 index 000000000..04e974133 --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_fluent_add_24_regular.xml @@ -0,0 +1,3 @@ + + + diff --git a/mastodon/src/main/res/drawable/ic_fluent_animal_cat_24_regular.xml b/mastodon/src/main/res/drawable/ic_fluent_animal_cat_24_regular.xml new file mode 100644 index 000000000..6203e61ed --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_fluent_animal_cat_24_regular.xml @@ -0,0 +1,3 @@ + + + diff --git a/mastodon/src/main/res/drawable/ic_fluent_animal_dog_24_regular.xml b/mastodon/src/main/res/drawable/ic_fluent_animal_dog_24_regular.xml new file mode 100644 index 000000000..53640efe7 --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_fluent_animal_dog_24_regular.xml @@ -0,0 +1,3 @@ + + + diff --git a/mastodon/src/main/res/drawable/ic_fluent_animal_rabbit_24_regular.xml b/mastodon/src/main/res/drawable/ic_fluent_animal_rabbit_24_regular.xml new file mode 100644 index 000000000..3925a7c2e --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_fluent_animal_rabbit_24_regular.xml @@ -0,0 +1,3 @@ + + + diff --git a/mastodon/src/main/res/drawable/ic_fluent_animal_turtle_24_regular.xml b/mastodon/src/main/res/drawable/ic_fluent_animal_turtle_24_regular.xml new file mode 100644 index 000000000..df5c66995 --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_fluent_animal_turtle_24_regular.xml @@ -0,0 +1,3 @@ + + + diff --git a/mastodon/src/main/res/drawable/ic_fluent_arrow_sync_24_regular.xml b/mastodon/src/main/res/drawable/ic_fluent_arrow_sync_24_regular.xml new file mode 100644 index 000000000..9c0cc31f0 --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_fluent_arrow_sync_24_regular.xml @@ -0,0 +1,3 @@ + + + diff --git a/mastodon/src/main/res/drawable/ic_fluent_backpack_24_regular.xml b/mastodon/src/main/res/drawable/ic_fluent_backpack_24_regular.xml new file mode 100644 index 000000000..8067b544d --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_fluent_backpack_24_regular.xml @@ -0,0 +1,3 @@ + + + diff --git a/mastodon/src/main/res/drawable/ic_fluent_balloon_24_regular.xml b/mastodon/src/main/res/drawable/ic_fluent_balloon_24_regular.xml new file mode 100644 index 000000000..23c61eb37 --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_fluent_balloon_24_regular.xml @@ -0,0 +1,3 @@ + + + diff --git a/mastodon/src/main/res/drawable/ic_fluent_book_open_24_regular.xml b/mastodon/src/main/res/drawable/ic_fluent_book_open_24_regular.xml new file mode 100644 index 000000000..c6c56d0dc --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_fluent_book_open_24_regular.xml @@ -0,0 +1,3 @@ + + + diff --git a/mastodon/src/main/res/drawable/ic_fluent_bot_24_regular.xml b/mastodon/src/main/res/drawable/ic_fluent_bot_24_regular.xml new file mode 100644 index 000000000..7f0d356af --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_fluent_bot_24_regular.xml @@ -0,0 +1,3 @@ + + + diff --git a/mastodon/src/main/res/drawable/ic_fluent_briefcase_24_regular.xml b/mastodon/src/main/res/drawable/ic_fluent_briefcase_24_regular.xml new file mode 100644 index 000000000..5edf325a2 --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_fluent_briefcase_24_regular.xml @@ -0,0 +1,3 @@ + + + diff --git a/mastodon/src/main/res/drawable/ic_fluent_bug_24_regular.xml b/mastodon/src/main/res/drawable/ic_fluent_bug_24_regular.xml new file mode 100644 index 000000000..4511feb89 --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_fluent_bug_24_regular.xml @@ -0,0 +1,3 @@ + + + diff --git a/mastodon/src/main/res/drawable/ic_fluent_chat_24_filled.xml b/mastodon/src/main/res/drawable/ic_fluent_chat_24_filled.xml new file mode 100644 index 000000000..4757c7084 --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_fluent_chat_24_filled.xml @@ -0,0 +1,3 @@ + + + diff --git a/mastodon/src/main/res/drawable/ic_fluent_chat_24_regular.xml b/mastodon/src/main/res/drawable/ic_fluent_chat_24_regular.xml new file mode 100644 index 000000000..0c0b72c3d --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_fluent_chat_24_regular.xml @@ -0,0 +1,3 @@ + + + diff --git a/mastodon/src/main/res/drawable/ic_fluent_chevron_down_20_filled.xml b/mastodon/src/main/res/drawable/ic_fluent_chevron_down_20_filled.xml new file mode 100644 index 000000000..c5dceeb7d --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_fluent_chevron_down_20_filled.xml @@ -0,0 +1,3 @@ + + + diff --git a/mastodon/src/main/res/drawable/ic_fluent_chevron_down_24_filled.xml b/mastodon/src/main/res/drawable/ic_fluent_chevron_down_24_filled.xml new file mode 100644 index 000000000..7916b3932 --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_fluent_chevron_down_24_filled.xml @@ -0,0 +1,3 @@ + + + diff --git a/mastodon/src/main/res/drawable/ic_fluent_city_24_regular.xml b/mastodon/src/main/res/drawable/ic_fluent_city_24_regular.xml new file mode 100644 index 000000000..d64de55fd --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_fluent_city_24_regular.xml @@ -0,0 +1,3 @@ + + + diff --git a/mastodon/src/main/res/drawable/ic_fluent_code_24_regular.xml b/mastodon/src/main/res/drawable/ic_fluent_code_24_regular.xml new file mode 100644 index 000000000..1ffd4232a --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_fluent_code_24_regular.xml @@ -0,0 +1,3 @@ + + + diff --git a/mastodon/src/main/res/drawable/ic_fluent_document_24_regular.xml b/mastodon/src/main/res/drawable/ic_fluent_document_24_regular.xml new file mode 100644 index 000000000..8fab94e5e --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_fluent_document_24_regular.xml @@ -0,0 +1,3 @@ + + + diff --git a/mastodon/src/main/res/drawable/ic_fluent_drink_coffee_24_regular.xml b/mastodon/src/main/res/drawable/ic_fluent_drink_coffee_24_regular.xml new file mode 100644 index 000000000..d164b5185 --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_fluent_drink_coffee_24_regular.xml @@ -0,0 +1,3 @@ + + + diff --git a/mastodon/src/main/res/drawable/ic_fluent_emoji_laugh_24_regular.xml b/mastodon/src/main/res/drawable/ic_fluent_emoji_laugh_24_regular.xml new file mode 100644 index 000000000..58c6c4554 --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_fluent_emoji_laugh_24_regular.xml @@ -0,0 +1,3 @@ + + + diff --git a/mastodon/src/main/res/drawable/ic_fluent_eye_20_regular.xml b/mastodon/src/main/res/drawable/ic_fluent_eye_20_regular.xml new file mode 100644 index 000000000..b00799b9f --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_fluent_eye_20_regular.xml @@ -0,0 +1,3 @@ + + + diff --git a/mastodon/src/main/res/drawable/ic_fluent_fire_24_regular.xml b/mastodon/src/main/res/drawable/ic_fluent_fire_24_regular.xml new file mode 100644 index 000000000..4d2868f9f --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_fluent_fire_24_regular.xml @@ -0,0 +1,3 @@ + + + diff --git a/mastodon/src/main/res/drawable/ic_fluent_food_pizza_24_regular.xml b/mastodon/src/main/res/drawable/ic_fluent_food_pizza_24_regular.xml new file mode 100644 index 000000000..5f36cf5c2 --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_fluent_food_pizza_24_regular.xml @@ -0,0 +1,3 @@ + + + diff --git a/mastodon/src/main/res/drawable/ic_fluent_games_24_regular.xml b/mastodon/src/main/res/drawable/ic_fluent_games_24_regular.xml new file mode 100644 index 000000000..b7f9497dd --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_fluent_games_24_regular.xml @@ -0,0 +1,3 @@ + + + diff --git a/mastodon/src/main/res/drawable/ic_fluent_gauge_24_regular.xml b/mastodon/src/main/res/drawable/ic_fluent_gauge_24_regular.xml new file mode 100644 index 000000000..8cabd534a --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_fluent_gauge_24_regular.xml @@ -0,0 +1,3 @@ + + + diff --git a/mastodon/src/main/res/drawable/ic_fluent_gavel_24_regular.xml b/mastodon/src/main/res/drawable/ic_fluent_gavel_24_regular.xml new file mode 100644 index 000000000..65496b10b --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_fluent_gavel_24_regular.xml @@ -0,0 +1,3 @@ + + + diff --git a/mastodon/src/main/res/drawable/ic_fluent_hat_graduation_24_regular.xml b/mastodon/src/main/res/drawable/ic_fluent_hat_graduation_24_regular.xml new file mode 100644 index 000000000..c21171199 --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_fluent_hat_graduation_24_regular.xml @@ -0,0 +1,3 @@ + + + diff --git a/mastodon/src/main/res/drawable/ic_fluent_headphones_sound_wave_24_regular.xml b/mastodon/src/main/res/drawable/ic_fluent_headphones_sound_wave_24_regular.xml new file mode 100644 index 000000000..b336b7b38 --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_fluent_headphones_sound_wave_24_regular.xml @@ -0,0 +1,3 @@ + + + diff --git a/mastodon/src/main/res/drawable/ic_fluent_heart_pulse_24_regular.xml b/mastodon/src/main/res/drawable/ic_fluent_heart_pulse_24_regular.xml new file mode 100644 index 000000000..902bcb8a2 --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_fluent_heart_pulse_24_regular.xml @@ -0,0 +1,3 @@ + + + diff --git a/mastodon/src/main/res/drawable/ic_fluent_history_24_filled.xml b/mastodon/src/main/res/drawable/ic_fluent_history_24_filled.xml new file mode 100644 index 000000000..b32bc18c0 --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_fluent_history_24_filled.xml @@ -0,0 +1,3 @@ + + + diff --git a/mastodon/src/main/res/drawable/ic_fluent_history_24_regular.xml b/mastodon/src/main/res/drawable/ic_fluent_history_24_regular.xml new file mode 100644 index 000000000..cf69e5f70 --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_fluent_history_24_regular.xml @@ -0,0 +1,3 @@ + + + diff --git a/mastodon/src/main/res/drawable/ic_fluent_image_add_24_regular.xml b/mastodon/src/main/res/drawable/ic_fluent_image_add_24_regular.xml new file mode 100644 index 000000000..6f44920f6 --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_fluent_image_add_24_regular.xml @@ -0,0 +1,3 @@ + + + diff --git a/mastodon/src/main/res/drawable/ic_fluent_important_16_filled.xml b/mastodon/src/main/res/drawable/ic_fluent_important_16_filled.xml new file mode 100644 index 000000000..97b4bd32b --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_fluent_important_16_filled.xml @@ -0,0 +1,3 @@ + + + diff --git a/mastodon/src/main/res/drawable/ic_fluent_important_20_filled.xml b/mastodon/src/main/res/drawable/ic_fluent_important_20_filled.xml new file mode 100644 index 000000000..703162835 --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_fluent_important_20_filled.xml @@ -0,0 +1,3 @@ + + + diff --git a/mastodon/src/main/res/drawable/ic_fluent_important_24_filled.xml b/mastodon/src/main/res/drawable/ic_fluent_important_24_filled.xml new file mode 100644 index 000000000..aa0db72ee --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_fluent_important_24_filled.xml @@ -0,0 +1,3 @@ + + + diff --git a/mastodon/src/main/res/drawable/ic_fluent_important_24_regular.xml b/mastodon/src/main/res/drawable/ic_fluent_important_24_regular.xml new file mode 100644 index 000000000..5a24a2b07 --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_fluent_important_24_regular.xml @@ -0,0 +1,3 @@ + + + diff --git a/mastodon/src/main/res/drawable/ic_fluent_leaf_three_24_regular.xml b/mastodon/src/main/res/drawable/ic_fluent_leaf_three_24_regular.xml new file mode 100644 index 000000000..72abc07ce --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_fluent_leaf_three_24_regular.xml @@ -0,0 +1,3 @@ + + + diff --git a/mastodon/src/main/res/drawable/ic_fluent_lightbulb_24_regular.xml b/mastodon/src/main/res/drawable/ic_fluent_lightbulb_24_regular.xml new file mode 100644 index 000000000..8f6699965 --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_fluent_lightbulb_24_regular.xml @@ -0,0 +1,3 @@ + + + diff --git a/mastodon/src/main/res/drawable/ic_fluent_list_24_regular.xml b/mastodon/src/main/res/drawable/ic_fluent_list_24_regular.xml new file mode 100644 index 000000000..e5f1bbbaf --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_fluent_list_24_regular.xml @@ -0,0 +1,3 @@ + + + diff --git a/mastodon/src/main/res/drawable/ic_fluent_list_28_regular.xml b/mastodon/src/main/res/drawable/ic_fluent_list_28_regular.xml new file mode 100644 index 000000000..2577df810 --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_fluent_list_28_regular.xml @@ -0,0 +1,3 @@ + + + diff --git a/mastodon/src/main/res/drawable/ic_fluent_local_language_24_regular.xml b/mastodon/src/main/res/drawable/ic_fluent_local_language_24_regular.xml new file mode 100644 index 000000000..6928902ad --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_fluent_local_language_24_regular.xml @@ -0,0 +1,3 @@ + + + diff --git a/mastodon/src/main/res/drawable/ic_fluent_location_24_regular.xml b/mastodon/src/main/res/drawable/ic_fluent_location_24_regular.xml new file mode 100644 index 000000000..10af130d0 --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_fluent_location_24_regular.xml @@ -0,0 +1,3 @@ + + + diff --git a/mastodon/src/main/res/drawable/ic_fluent_lock_closed_20_regular.xml b/mastodon/src/main/res/drawable/ic_fluent_lock_closed_20_regular.xml new file mode 100644 index 000000000..e46089499 --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_fluent_lock_closed_20_regular.xml @@ -0,0 +1,3 @@ + + + diff --git a/mastodon/src/main/res/drawable/ic_fluent_lock_closed_24_filled.xml b/mastodon/src/main/res/drawable/ic_fluent_lock_closed_24_filled.xml new file mode 100644 index 000000000..213c715c9 --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_fluent_lock_closed_24_filled.xml @@ -0,0 +1,3 @@ + + + diff --git a/mastodon/src/main/res/drawable/ic_fluent_lock_closed_24_regular.xml b/mastodon/src/main/res/drawable/ic_fluent_lock_closed_24_regular.xml new file mode 100644 index 000000000..b9889347b --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_fluent_lock_closed_24_regular.xml @@ -0,0 +1,3 @@ + + + diff --git a/mastodon/src/main/res/drawable/ic_fluent_lock_open_20_regular.xml b/mastodon/src/main/res/drawable/ic_fluent_lock_open_20_regular.xml new file mode 100644 index 000000000..c2648899b --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_fluent_lock_open_20_regular.xml @@ -0,0 +1,3 @@ + + + diff --git a/mastodon/src/main/res/drawable/ic_fluent_lock_open_24_regular.xml b/mastodon/src/main/res/drawable/ic_fluent_lock_open_24_regular.xml new file mode 100644 index 000000000..4e1ed6b76 --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_fluent_lock_open_24_regular.xml @@ -0,0 +1,3 @@ + + + diff --git a/mastodon/src/main/res/drawable/ic_fluent_map_24_regular.xml b/mastodon/src/main/res/drawable/ic_fluent_map_24_regular.xml new file mode 100644 index 000000000..183efac0a --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_fluent_map_24_regular.xml @@ -0,0 +1,3 @@ + + + diff --git a/mastodon/src/main/res/drawable/ic_fluent_math_formula_24_regular.xml b/mastodon/src/main/res/drawable/ic_fluent_math_formula_24_regular.xml new file mode 100644 index 000000000..45aaedb41 --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_fluent_math_formula_24_regular.xml @@ -0,0 +1,3 @@ + + + diff --git a/mastodon/src/main/res/drawable/ic_fluent_megaphone_loud_24_regular.xml b/mastodon/src/main/res/drawable/ic_fluent_megaphone_loud_24_regular.xml new file mode 100644 index 000000000..83a99f4da --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_fluent_megaphone_loud_24_regular.xml @@ -0,0 +1,3 @@ + + + diff --git a/mastodon/src/main/res/drawable/ic_fluent_mic_24_regular.xml b/mastodon/src/main/res/drawable/ic_fluent_mic_24_regular.xml new file mode 100644 index 000000000..720623a9b --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_fluent_mic_24_regular.xml @@ -0,0 +1,3 @@ + + + diff --git a/mastodon/src/main/res/drawable/ic_fluent_microscope_24_regular.xml b/mastodon/src/main/res/drawable/ic_fluent_microscope_24_regular.xml new file mode 100644 index 000000000..ffdb9b096 --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_fluent_microscope_24_regular.xml @@ -0,0 +1,3 @@ + + + diff --git a/mastodon/src/main/res/drawable/ic_fluent_midi_24_regular.xml b/mastodon/src/main/res/drawable/ic_fluent_midi_24_regular.xml new file mode 100644 index 000000000..e2593a7d9 --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_fluent_midi_24_regular.xml @@ -0,0 +1,3 @@ + + + diff --git a/mastodon/src/main/res/drawable/ic_fluent_more_horizontal_24_regular.xml b/mastodon/src/main/res/drawable/ic_fluent_more_horizontal_24_regular.xml new file mode 100644 index 000000000..de1128305 --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_fluent_more_horizontal_24_regular.xml @@ -0,0 +1,3 @@ + + + diff --git a/mastodon/src/main/res/drawable/ic_fluent_movies_and_tv_24_regular.xml b/mastodon/src/main/res/drawable/ic_fluent_movies_and_tv_24_regular.xml new file mode 100644 index 000000000..0cd018717 --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_fluent_movies_and_tv_24_regular.xml @@ -0,0 +1,3 @@ + + + diff --git a/mastodon/src/main/res/drawable/ic_fluent_music_note_2_24_regular.xml b/mastodon/src/main/res/drawable/ic_fluent_music_note_2_24_regular.xml new file mode 100644 index 000000000..4ced5601b --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_fluent_music_note_2_24_regular.xml @@ -0,0 +1,3 @@ + + + diff --git a/mastodon/src/main/res/drawable/ic_fluent_news_24_regular.xml b/mastodon/src/main/res/drawable/ic_fluent_news_24_regular.xml new file mode 100644 index 000000000..bcb6f260b --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_fluent_news_24_regular.xml @@ -0,0 +1,3 @@ + + + diff --git a/mastodon/src/main/res/drawable/ic_fluent_number_symbol_20_filled.xml b/mastodon/src/main/res/drawable/ic_fluent_number_symbol_20_filled.xml new file mode 100644 index 000000000..99f609242 --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_fluent_number_symbol_20_filled.xml @@ -0,0 +1,3 @@ + + + diff --git a/mastodon/src/main/res/drawable/ic_fluent_people_28_regular.xml b/mastodon/src/main/res/drawable/ic_fluent_people_28_regular.xml new file mode 100644 index 000000000..088bfe2e5 --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_fluent_people_28_regular.xml @@ -0,0 +1,3 @@ + + + diff --git a/mastodon/src/main/res/drawable/ic_fluent_person_available_24_filled.xml b/mastodon/src/main/res/drawable/ic_fluent_person_available_24_filled.xml new file mode 100644 index 000000000..4142f8205 --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_fluent_person_available_24_filled.xml @@ -0,0 +1,3 @@ + + + diff --git a/mastodon/src/main/res/drawable/ic_fluent_pi_24_regular.xml b/mastodon/src/main/res/drawable/ic_fluent_pi_24_regular.xml new file mode 100644 index 000000000..b464e656b --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_fluent_pi_24_regular.xml @@ -0,0 +1,3 @@ + + + diff --git a/mastodon/src/main/res/drawable/ic_fluent_pin_24_filled.xml b/mastodon/src/main/res/drawable/ic_fluent_pin_24_filled.xml new file mode 100644 index 000000000..493dc05be --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_fluent_pin_24_filled.xml @@ -0,0 +1,3 @@ + + + diff --git a/mastodon/src/main/res/drawable/ic_fluent_scan_24_regular.xml b/mastodon/src/main/res/drawable/ic_fluent_scan_24_regular.xml new file mode 100644 index 000000000..3f94335f9 --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_fluent_scan_24_regular.xml @@ -0,0 +1,3 @@ + + + diff --git a/mastodon/src/main/res/drawable/ic_fluent_scan_text_24_regular.xml b/mastodon/src/main/res/drawable/ic_fluent_scan_text_24_regular.xml new file mode 100644 index 000000000..37c301a21 --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_fluent_scan_text_24_regular.xml @@ -0,0 +1,3 @@ + + + diff --git a/mastodon/src/main/res/drawable/ic_fluent_shield_24_regular.xml b/mastodon/src/main/res/drawable/ic_fluent_shield_24_regular.xml new file mode 100644 index 000000000..98ecd4947 --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_fluent_shield_24_regular.xml @@ -0,0 +1,3 @@ + + + diff --git a/mastodon/src/main/res/drawable/ic_fluent_sport_24_regular.xml b/mastodon/src/main/res/drawable/ic_fluent_sport_24_regular.xml new file mode 100644 index 000000000..72d513fa2 --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_fluent_sport_24_regular.xml @@ -0,0 +1,3 @@ + + + diff --git a/mastodon/src/main/res/drawable/ic_fluent_stethoscope_24_regular.xml b/mastodon/src/main/res/drawable/ic_fluent_stethoscope_24_regular.xml new file mode 100644 index 000000000..af3311024 --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_fluent_stethoscope_24_regular.xml @@ -0,0 +1,3 @@ + + + diff --git a/mastodon/src/main/res/drawable/ic_fluent_tag_24_regular.xml b/mastodon/src/main/res/drawable/ic_fluent_tag_24_regular.xml new file mode 100644 index 000000000..e3604263a --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_fluent_tag_24_regular.xml @@ -0,0 +1,3 @@ + + + diff --git a/mastodon/src/main/res/drawable/ic_fluent_timeline_24_regular.xml b/mastodon/src/main/res/drawable/ic_fluent_timeline_24_regular.xml new file mode 100644 index 000000000..db79b17ac --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_fluent_timeline_24_regular.xml @@ -0,0 +1,3 @@ + + + diff --git a/mastodon/src/main/res/drawable/ic_fluent_vehicle_bicycle_24_regular.xml b/mastodon/src/main/res/drawable/ic_fluent_vehicle_bicycle_24_regular.xml new file mode 100644 index 000000000..5327229f9 --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_fluent_vehicle_bicycle_24_regular.xml @@ -0,0 +1,3 @@ + + + diff --git a/mastodon/src/main/res/drawable/ic_fluent_vehicle_subway_24_regular.xml b/mastodon/src/main/res/drawable/ic_fluent_vehicle_subway_24_regular.xml new file mode 100644 index 000000000..65570be21 --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_fluent_vehicle_subway_24_regular.xml @@ -0,0 +1,3 @@ + + + diff --git a/mastodon/src/main/res/drawable/ic_fluent_warning_24_filled.xml b/mastodon/src/main/res/drawable/ic_fluent_warning_24_filled.xml new file mode 100644 index 000000000..8a40cb995 --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_fluent_warning_24_filled.xml @@ -0,0 +1,3 @@ + + + diff --git a/mastodon/src/main/res/drawable/ic_fluent_weather_rain_showers_day_24_regular.xml b/mastodon/src/main/res/drawable/ic_fluent_weather_rain_showers_day_24_regular.xml new file mode 100644 index 000000000..2884433fa --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_fluent_weather_rain_showers_day_24_regular.xml @@ -0,0 +1,3 @@ + + + diff --git a/mastodon/src/main/res/drawable/splash_logo.xml b/mastodon/src/main/res/drawable/splash_logo.xml deleted file mode 100644 index 0486618e4..000000000 --- a/mastodon/src/main/res/drawable/splash_logo.xml +++ /dev/null @@ -1,27 +0,0 @@ - - - - - - - - - - - - diff --git a/mastodon/src/main/res/layout/alt_badge.xml b/mastodon/src/main/res/layout/alt_badge.xml new file mode 100644 index 000000000..f165466cf --- /dev/null +++ b/mastodon/src/main/res/layout/alt_badge.xml @@ -0,0 +1,79 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/mastodon/src/main/res/layout/compose_action.xml b/mastodon/src/main/res/layout/compose_action.xml index 800224b96..5b2bfa90e 100644 --- a/mastodon/src/main/res/layout/compose_action.xml +++ b/mastodon/src/main/res/layout/compose_action.xml @@ -42,7 +42,6 @@ android:id="@+id/drafts_btn" android:layout_width="wrap_content" android:layout_height="wrap_content" - android:layout_marginEnd="8dp" android:paddingHorizontal="8dp" android:drawableStart="@drawable/ic_fluent_clock_20_regular" android:drawableTint="?android:textColorSecondary" @@ -55,6 +54,7 @@ android:id="@+id/publish_btn" android:layout_width="wrap_content" android:layout_height="wrap_content" + android:layout_marginStart="8dp" android:singleLine="true" android:ellipsize="end" /> diff --git a/mastodon/src/main/res/layout/compose_fab.xml b/mastodon/src/main/res/layout/compose_fab.xml new file mode 100644 index 000000000..e2017ac5c --- /dev/null +++ b/mastodon/src/main/res/layout/compose_fab.xml @@ -0,0 +1,5 @@ + + diff --git a/mastodon/src/main/res/layout/display_item_filter_warning.xml b/mastodon/src/main/res/layout/display_item_filter_warning.xml new file mode 100644 index 000000000..a2ac8bf86 --- /dev/null +++ b/mastodon/src/main/res/layout/display_item_filter_warning.xml @@ -0,0 +1,30 @@ + + + + + + + + \ No newline at end of file diff --git a/mastodon/src/main/res/layout/display_item_footer.xml b/mastodon/src/main/res/layout/display_item_footer.xml index 50cd49874..99896e58f 100644 --- a/mastodon/src/main/res/layout/display_item_footer.xml +++ b/mastodon/src/main/res/layout/display_item_footer.xml @@ -88,10 +88,10 @@ @@ -109,7 +109,7 @@ + + \ No newline at end of file diff --git a/mastodon/src/main/res/layout/display_item_header.xml b/mastodon/src/main/res/layout/display_item_header.xml index 5c307d82e..be6903169 100644 --- a/mastodon/src/main/res/layout/display_item_header.xml +++ b/mastodon/src/main/res/layout/display_item_header.xml @@ -3,9 +3,9 @@ xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent" android:layout_height="wrap_content" - android:paddingTop="16dp" - android:paddingRight="16dp" - android:paddingLeft="16dp"> + android:paddingTop="13dp" + android:paddingEnd="4dp" + android:paddingStart="16dp"> + + + + + + + + + android:layout_marginEnd="12dp" + android:layout_marginTop="3dp" /> @@ -90,7 +109,7 @@ android:id="@+id/extra_text" android:layout_width="wrap_content" android:layout_height="wrap_content" - android:layout_marginStart="8dp" + android:layout_marginStart="8sp" android:ellipsize="end" android:fontFamily="sans-serif" android:singleLine="true" @@ -127,7 +146,7 @@ android:layout_marginLeft="4sp" android:layout_marginRight="4sp" android:importantForAccessibility="no" - android:text="·" + android:text="@string/sk_separator" android:textAppearance="@style/m3_title_small" /> + + \ No newline at end of file diff --git a/mastodon/src/main/res/layout/display_item_text.xml b/mastodon/src/main/res/layout/display_item_text.xml index 15907788e..6553146c9 100644 --- a/mastodon/src/main/res/layout/display_item_text.xml +++ b/mastodon/src/main/res/layout/display_item_text.xml @@ -44,19 +44,48 @@ android:background="?attr/colorPollVoted"/> - + android:requiresFadingEdge="vertical" + android:scrollbars="none" + android:fadingEdgeLength="36dp"> + + + + + + + +