diff --git a/mastodon/src/main/AndroidManifest.xml b/mastodon/src/main/AndroidManifest.xml index 5c1c0a2c9..2be856c45 100644 --- a/mastodon/src/main/AndroidManifest.xml +++ b/mastodon/src/main/AndroidManifest.xml @@ -1,6 +1,6 @@ - + @@ -30,7 +30,6 @@ android:supportsRtl="true" android:localeConfig="@xml/locales_config" android:icon="@mipmap/ic_launcher" - android:roundIcon="@mipmap/ic_launcher_round" android:theme="@style/Theme.Mastodon.AutoLightDark" android:windowSoftInputMode="adjustPan" android:largeHeap="true"> @@ -49,7 +48,6 @@ android:theme="@android:style/Theme.NoDisplay"> - @@ -92,6 +90,15 @@ + + + + + + + + + + + + + +{{content}} + + \ No newline at end of file diff --git a/mastodon/src/main/java/org/joinmastodon/android/AudioPlayerService.java b/mastodon/src/main/java/org/joinmastodon/android/AudioPlayerService.java index ab0c29d1d..362c2fb02 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/AudioPlayerService.java +++ b/mastodon/src/main/java/org/joinmastodon/android/AudioPlayerService.java @@ -31,7 +31,6 @@ import org.joinmastodon.android.ui.text.HtmlParser; import org.parceler.Parcels; import java.io.IOException; -import java.util.ArrayList; import java.util.HashSet; import androidx.annotation.Nullable; @@ -57,6 +56,7 @@ public class AudioPlayerService extends Service{ private static HashSet callbacks=new HashSet<>(); private AudioManager.OnAudioFocusChangeListener audioFocusChangeListener=this::onAudioFocusChanged; private boolean resumeAfterAudioFocusGain; + private boolean isBuffering=true; private BroadcastReceiver receiver=new BroadcastReceiver(){ @Override @@ -169,13 +169,15 @@ public class AudioPlayerService extends Service{ } updateNotification(false, false); - getSystemService(AudioManager.class).requestAudioFocus(audioFocusChangeListener, AudioManager.STREAM_MUSIC, AudioManager.AUDIOFOCUS_GAIN); + int audiofocus = GlobalUserPreferences.overlayMedia ? AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_MAY_DUCK : AudioManager.AUDIOFOCUS_GAIN; + getSystemService(AudioManager.class).requestAudioFocus(audioFocusChangeListener, AudioManager.STREAM_MUSIC, audiofocus); player=new MediaPlayer(); player.setOnPreparedListener(this::onPlayerPrepared); player.setOnErrorListener(this::onPlayerError); player.setOnCompletionListener(this::onPlayerCompletion); player.setOnSeekCompleteListener(this::onPlayerSeekCompleted); + player.setOnInfoListener(this::onPlayerInfo); try{ player.setDataSource(this, Uri.parse(attachment.url)); player.prepareAsync(); @@ -187,7 +189,9 @@ public class AudioPlayerService extends Service{ } private void onPlayerPrepared(MediaPlayer mp){ + Log.i(TAG, "onPlayerPrepared"); playerReady=true; + isBuffering=false; player.start(); updateSessionState(false); } @@ -205,6 +209,21 @@ public class AudioPlayerService extends Service{ stopSelf(); } + private boolean onPlayerInfo(MediaPlayer mp, int what, int extra){ + switch(what){ + case MediaPlayer.MEDIA_INFO_BUFFERING_START -> { + isBuffering=true; + updateSessionState(false); + } + case MediaPlayer.MEDIA_INFO_BUFFERING_END -> { + isBuffering=false; + updateSessionState(false); + } + default -> Log.i(TAG, "onPlayerInfo() called with: mp = ["+mp+"], what = ["+what+"], extra = ["+extra+"]"); + } + return true; + } + private void onAudioFocusChanged(int change){ switch(change){ case AudioManager.AUDIOFOCUS_LOSS -> { @@ -212,7 +231,7 @@ public class AudioPlayerService extends Service{ pause(false); } case AudioManager.AUDIOFOCUS_LOSS_TRANSIENT -> { - resumeAfterAudioFocusGain=true; + resumeAfterAudioFocusGain=isPlaying(); pause(false); } case AudioManager.AUDIOFOCUS_GAIN -> { @@ -232,12 +251,16 @@ public class AudioPlayerService extends Service{ private void updateSessionState(boolean removeNotification){ session.setPlaybackState(new PlaybackState.Builder() - .setState(player.isPlaying() ? PlaybackState.STATE_PLAYING : PlaybackState.STATE_PAUSED, player.getCurrentPosition(), 1f) + .setState(switch(getPlayState()){ + case PLAYING -> PlaybackState.STATE_PLAYING; + case PAUSED -> PlaybackState.STATE_PAUSED; + case BUFFERING -> PlaybackState.STATE_BUFFERING; + }, player.getCurrentPosition(), 1f) .setActions(PlaybackState.ACTION_STOP | PlaybackState.ACTION_PLAY_PAUSE | PlaybackState.ACTION_SEEK_TO) .build()); updateNotification(!player.isPlaying(), removeNotification); for(Callback cb:callbacks) - cb.onPlayStateChanged(attachment.id, player.isPlaying(), player.getCurrentPosition()); + cb.onPlayStateChanged(attachment.id, getPlayState(), player.getCurrentPosition()); } private void updateNotification(boolean dismissable, boolean removeNotification){ @@ -310,6 +333,12 @@ public class AudioPlayerService extends Service{ return attachment.id; } + public PlayState getPlayState(){ + if(isBuffering) + return PlayState.BUFFERING; + return player.isPlaying() ? PlayState.PLAYING : PlayState.PAUSED; + } + public static void registerCallback(Callback cb){ callbacks.add(cb); } @@ -333,7 +362,13 @@ public class AudioPlayerService extends Service{ } public interface Callback{ - void onPlayStateChanged(String attachmentID, boolean playing, int position); + void onPlayStateChanged(String attachmentID, PlayState state, int position); void onPlaybackStopped(String attachmentID); } + + public enum PlayState{ + PLAYING, + PAUSED, + BUFFERING + } } diff --git a/mastodon/src/main/java/org/joinmastodon/android/GlobalUserPreferences.java b/mastodon/src/main/java/org/joinmastodon/android/GlobalUserPreferences.java index 129125038..491142e93 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/GlobalUserPreferences.java +++ b/mastodon/src/main/java/org/joinmastodon/android/GlobalUserPreferences.java @@ -4,142 +4,147 @@ import static org.joinmastodon.android.api.MastodonAPIController.gson; import android.content.Context; import android.content.SharedPreferences; +import android.util.Log; + +import androidx.annotation.StringRes; import android.os.Build; import com.google.gson.JsonSyntaxException; import com.google.gson.reflect.TypeToken; +import org.joinmastodon.android.api.session.AccountLocalPreferences; +import org.joinmastodon.android.api.session.AccountSession; +import org.joinmastodon.android.api.session.AccountSessionManager; import org.joinmastodon.android.model.ContentType; +import org.joinmastodon.android.model.Instance; import org.joinmastodon.android.model.TimelineDefinition; import org.joinmastodon.android.ui.utils.ColorPalette; import java.lang.reflect.Type; +import java.util.ArrayList; import java.util.HashMap; import java.util.HashSet; -import java.util.List; import java.util.Map; import java.util.Set; public class GlobalUserPreferences{ + private static final String TAG="GlobalUserPreferences"; + public static boolean playGifs; public static boolean useCustomTabs; + public static boolean altTextReminders, confirmUnfollow, confirmBoost, confirmDeletePost; + public static ThemePreference theme; + + // MEGALODON public static boolean trueBlackTheme; - public static boolean showReplies; - public static boolean showBoosts; public static boolean loadNewPosts; public static boolean showNewPostsButton; - public static boolean showInteractionCounts; - public static boolean alwaysExpandContentWarnings; - public static boolean disableMarquee; + public static boolean toolbarMarquee; public static boolean disableSwipe; - public static boolean showDividers; public static boolean voteButtonForSingleChoice; public static boolean enableDeleteNotifications; public static boolean translateButtonOpenedOnly; public static boolean uniformNotificationIcon; - public static boolean relocatePublishButton; public static boolean reduceMotion; - public static boolean keepOnlyLatestNotification; - public static boolean disableAltTextReminder; public static boolean showAltIndicator; public static boolean showNoAltIndicator; public static boolean enablePreReleases; public static PrefixRepliesMode prefixReplies; - public static boolean bottomEncoding; public static boolean collapseLongPosts; public static boolean spectatorMode; public static boolean autoHideFab; + public static boolean compactReblogReplyLine; + public static boolean allowRemoteLoading; + public static boolean forwardReportDefault; + public static AutoRevealMode autoRevealEqualSpoilers; + public static ColorPreference color; + public static boolean disableM3PillActiveIndicator; + public static boolean showNavigationLabels; + public static boolean displayPronounsInTimelines, displayPronounsInThreads, displayPronounsInUserListings; + public static boolean overlayMedia; + + // MOSHIDON + public static boolean showDividers; + public static boolean relocatePublishButton; public static boolean defaultToUnlistedReplies; public static boolean doubleTapToSwipe; - public static boolean compactReblogReplyLine; public static boolean confirmBeforeReblog; public static boolean hapticFeedback; public static boolean replyLineAboveHeader; public static boolean swapBookmarkWithBoostAction; public static boolean loadRemoteAccountFollowers; public static boolean mentionRebloggerAutomatically; - public static boolean allowRemoteLoading; - public static boolean forwardReportDefault; - public static AutoRevealMode autoRevealEqualSpoilers; - public static String publishButtonText; - public static ThemePreference theme; - public static ColorPreference color; - - public static Map> recentLanguages; - public static Map> pinnedTimelines; - public static Set accountsWithLocalOnlySupport; - public static Set accountsInGlitchMode; - public static Set accountsWithContentTypesEnabled; - public static Map accountsDefaultContentTypes; - - private final static Type recentLanguagesType = new TypeToken>>() {}.getType(); - private final static Type pinnedTimelinesType = new TypeToken>>() {}.getType(); - private final static Type accountsDefaultContentTypesType = new TypeToken>() {}.getType(); - - private final static Type recentEmojisType = new TypeToken>() {}.getType(); - public static Map recentEmojis; - - /** - * Pleroma - */ - public static String replyVisibility; public 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; } + public static T fromJson(String json, Type type, T orElse){ + if(json==null) return orElse; + try{ + T value=gson.fromJson(json, type); + return value==null ? orElse : value; + }catch(JsonSyntaxException ignored){ + return orElse; + } } - public static void removeAccount(String accountId) { - recentLanguages.remove(accountId); - pinnedTimelines.remove(accountId); - accountsInGlitchMode.remove(accountId); - accountsWithLocalOnlySupport.remove(accountId); - accountsWithContentTypesEnabled.remove(accountId); - accountsDefaultContentTypes.remove(accountId); - save(); + public static > T enumValue(Class enumType, String name) { + try { return Enum.valueOf(enumType, name); } + catch (NullPointerException npe) { return null; } } public static void load(){ SharedPreferences prefs=getPrefs(); + playGifs=prefs.getBoolean("playGifs", true); useCustomTabs=prefs.getBoolean("useCustomTabs", true); + theme=ThemePreference.values()[prefs.getInt("theme", 0)]; + altTextReminders=prefs.getBoolean("altTextReminders", true); + confirmUnfollow=prefs.getBoolean("confirmUnfollow", true); + confirmBoost=prefs.getBoolean("confirmBoost", false); + confirmDeletePost=prefs.getBoolean("confirmDeletePost", true); + + // MEGALODON trueBlackTheme=prefs.getBoolean("trueBlackTheme", false); - showReplies=prefs.getBoolean("showReplies", true); - showBoosts=prefs.getBoolean("showBoosts", true); loadNewPosts=prefs.getBoolean("loadNewPosts", true); showNewPostsButton=prefs.getBoolean("showNewPostsButton", true); - uniformNotificationIcon=prefs.getBoolean("uniformNotificationIcon", false); - showInteractionCounts=prefs.getBoolean("showInteractionCounts", false); - alwaysExpandContentWarnings=prefs.getBoolean("alwaysExpandContentWarnings", false); - disableMarquee=prefs.getBoolean("disableMarquee", false); + toolbarMarquee=prefs.getBoolean("toolbarMarquee", true); disableSwipe=prefs.getBoolean("disableSwipe", false); - showDividers =prefs.getBoolean("showDividers", false); - relocatePublishButton=prefs.getBoolean("relocatePublishButton", true); voteButtonForSingleChoice=prefs.getBoolean("voteButtonForSingleChoice", true); enableDeleteNotifications=prefs.getBoolean("enableDeleteNotifications", false); translateButtonOpenedOnly=prefs.getBoolean("translateButtonOpenedOnly", false); 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); prefixReplies=PrefixRepliesMode.valueOf(prefs.getString("prefixReplies", PrefixRepliesMode.NEVER.name())); - bottomEncoding=prefs.getBoolean("bottomEncoding", false); collapseLongPosts=prefs.getBoolean("collapseLongPosts", true); spectatorMode=prefs.getBoolean("spectatorMode", false); autoHideFab=prefs.getBoolean("autoHideFab", true); compactReblogReplyLine=prefs.getBoolean("compactReblogReplyLine", true); + allowRemoteLoading=prefs.getBoolean("allowRemoteLoading", true); + autoRevealEqualSpoilers=AutoRevealMode.valueOf(prefs.getString("autoRevealEqualSpoilers", AutoRevealMode.THREADS.name())); + forwardReportDefault=prefs.getBoolean("forwardReportDefault", true); + disableM3PillActiveIndicator=prefs.getBoolean("disableM3PillActiveIndicator", false); + showNavigationLabels=prefs.getBoolean("showNavigationLabels", true); + displayPronounsInTimelines=prefs.getBoolean("displayPronounsInTimelines", true); + displayPronounsInThreads=prefs.getBoolean("displayPronounsInThreads", true); + displayPronounsInUserListings=prefs.getBoolean("displayPronounsInUserListings", true); + overlayMedia=prefs.getBoolean("overlayMedia", false); + + // MOSHIDON + uniformNotificationIcon=prefs.getBoolean("uniformNotificationIcon", false); + showInteractionCounts=prefs.getBoolean("showInteractionCounts", false); + alwaysExpandContentWarnings=prefs.getBoolean("alwaysExpandContentWarnings", false); + disableMarquee=prefs.getBoolean("disableMarquee", false); + showDividers =prefs.getBoolean("showDividers", false); + relocatePublishButton=prefs.getBoolean("relocatePublishButton", true); + compactReblogReplyLine=prefs.getBoolean("compactReblogReplyLine", true); defaultToUnlistedReplies=prefs.getBoolean("defaultToUnlistedReplies", false); doubleTapToSwipe =prefs.getBoolean("doubleTapToSwipe", true); replyLineAboveHeader=prefs.getBoolean("replyLineAboveHeader", true); - compactReblogReplyLine=prefs.getBoolean("compactReblogReplyLine", true); confirmBeforeReblog=prefs.getBoolean("confirmBeforeReblog", false); hapticFeedback=prefs.getBoolean("hapticFeedback", true); swapBookmarkWithBoostAction=prefs.getBoolean("swapBookmarkWithBoostAction", false); @@ -156,9 +161,8 @@ public class GlobalUserPreferences{ replyVisibility=prefs.getString("replyVisibility", null); accountsWithContentTypesEnabled=prefs.getStringSet("accountsWithContentTypesEnabled", new HashSet<>()); accountsDefaultContentTypes=fromJson(prefs.getString("accountsDefaultContentTypes", null), accountsDefaultContentTypesType, new HashMap<>()); - allowRemoteLoading=prefs.getBoolean("allowRemoteLoading", true); - autoRevealEqualSpoilers=AutoRevealMode.valueOf(prefs.getString("autoRevealEqualSpoilers", AutoRevealMode.THREADS.name())); - forwardReportDefault=prefs.getBoolean("forwardReportDefault", true); + + if (prefs.contains("prefixRepliesWithRe")) { prefixReplies = prefs.getBoolean("prefixRepliesWithRe", false) @@ -179,30 +183,30 @@ public class GlobalUserPreferences{ // invalid color name or color was previously saved as integer color=ColorPreference.PURPLE; } + + if(prefs.getInt("migrationLevel", 0) < 61) migrateToUpstreamVersion61(); } public static void save(){ getPrefs().edit() .putBoolean("playGifs", playGifs) .putBoolean("useCustomTabs", useCustomTabs) - .putBoolean("showReplies", showReplies) - .putBoolean("showBoosts", showBoosts) + .putInt("theme", theme.ordinal()) + .putBoolean("altTextReminders", altTextReminders) + .putBoolean("confirmUnfollow", confirmUnfollow) + .putBoolean("confirmBoost", confirmBoost) + .putBoolean("confirmDeletePost", confirmDeletePost) + + // MEGALODON .putBoolean("loadNewPosts", loadNewPosts) .putBoolean("showNewPostsButton", showNewPostsButton) .putBoolean("trueBlackTheme", trueBlackTheme) - .putBoolean("showInteractionCounts", showInteractionCounts) - .putBoolean("alwaysExpandContentWarnings", alwaysExpandContentWarnings) - .putBoolean("disableMarquee", disableMarquee) + .putBoolean("toolbarMarquee", toolbarMarquee) .putBoolean("disableSwipe", disableSwipe) .putBoolean("enableDeleteNotifications", enableDeleteNotifications) .putBoolean("translateButtonOpenedOnly", translateButtonOpenedOnly) - .putBoolean("showDividers", showDividers) - .putBoolean("relocatePublishButton", relocatePublishButton) .putBoolean("uniformNotificationIcon", uniformNotificationIcon) - .putBoolean("enableDeleteNotifications", enableDeleteNotifications) .putBoolean("reduceMotion", reduceMotion) - .putBoolean("keepOnlyLatestNotification", keepOnlyLatestNotification) - .putBoolean("disableAltTextReminder", disableAltTextReminder) .putBoolean("showAltIndicator", showAltIndicator) .putBoolean("showNoAltIndicator", showNoAltIndicator) .putBoolean("enablePreReleases", enablePreReleases) @@ -211,6 +215,26 @@ public class GlobalUserPreferences{ .putBoolean("spectatorMode", spectatorMode) .putBoolean("autoHideFab", autoHideFab) .putBoolean("compactReblogReplyLine", compactReblogReplyLine) + .putString("color", color.name()) + .putBoolean("allowRemoteLoading", allowRemoteLoading) + .putString("autoRevealEqualSpoilers", autoRevealEqualSpoilers.name()) + .putBoolean("forwardReportDefault", forwardReportDefault) + .putBoolean("disableM3PillActiveIndicator", disableM3PillActiveIndicator) + .putBoolean("showNavigationLabels", showNavigationLabels) + .putBoolean("displayPronounsInTimelines", displayPronounsInTimelines) + .putBoolean("displayPronounsInThreads", displayPronounsInThreads) + .putBoolean("displayPronounsInUserListings", displayPronounsInUserListings) + .putBoolean("overlayMedia", overlayMedia) + + // MOSHIDON + .putString("recentLanguages", gson.toJson(recentLanguages)) + .putString("pinnedTimelines", gson.toJson(pinnedTimelines)) + .putString("recentEmojis", gson.toJson(recentEmojis)) + .putStringSet("accountsWithLocalOnlySupport", accountsWithLocalOnlySupport) + .putStringSet("accountsInGlitchMode", accountsInGlitchMode) + .putString("replyVisibility", replyVisibility) + .putStringSet("accountsWithContentTypesEnabled", accountsWithContentTypesEnabled) + .putString("accountsDefaultContentTypes", gson.toJson(accountsDefaultContentTypes)) .putString("publishButtonText", publishButtonText) .putBoolean("bottomEncoding", bottomEncoding) .putBoolean("defaultToUnlistedReplies", defaultToUnlistedReplies) @@ -221,22 +245,64 @@ public class GlobalUserPreferences{ .putBoolean("swapBookmarkWithBoostAction", swapBookmarkWithBoostAction) .putBoolean("loadRemoteAccountFollowers", loadRemoteAccountFollowers) .putBoolean("mentionRebloggerAutomatically", mentionRebloggerAutomatically) + .putBoolean("showDividers", showDividers) + .putBoolean("relocatePublishButton", relocatePublishButton) + .putBoolean("enableDeleteNotifications", enableDeleteNotifications) .putInt("theme", theme.ordinal()) - .putString("color", color.name()) - .putString("recentLanguages", gson.toJson(recentLanguages)) - .putString("pinnedTimelines", gson.toJson(pinnedTimelines)) - .putString("recentEmojis", gson.toJson(recentEmojis)) - .putStringSet("accountsWithLocalOnlySupport", accountsWithLocalOnlySupport) - .putStringSet("accountsInGlitchMode", accountsInGlitchMode) - .putString("replyVisibility", replyVisibility) - .putStringSet("accountsWithContentTypesEnabled", accountsWithContentTypesEnabled) - .putString("accountsDefaultContentTypes", gson.toJson(accountsDefaultContentTypes)) - .putBoolean("allowRemoteLoading", allowRemoteLoading) - .putString("autoRevealEqualSpoilers", autoRevealEqualSpoilers.name()) - .putBoolean("forwardReportDefault", forwardReportDefault) + .apply(); } + private static void migrateToUpstreamVersion61(){ + Log.d(TAG, "Migrating preferences to upstream version 61!!"); + + Type accountsDefaultContentTypesType = new TypeToken>() {}.getType(); + Type pinnedTimelinesType = new TypeToken>>() {}.getType(); + Type recentLanguagesType = new TypeToken>>() {}.getType(); + + // migrate global preferences + SharedPreferences prefs=getPrefs(); + altTextReminders=!prefs.getBoolean("disableAltTextReminder", false); + confirmBoost=prefs.getBoolean("confirmBeforeReblog", false); + toolbarMarquee=!prefs.getBoolean("disableMarquee", false); + + save(); + + // migrate local preferences + AccountSessionManager asm=AccountSessionManager.getInstance(); + // reset: Set accountsWithContentTypesEnabled=prefs.getStringSet("accountsWithContentTypesEnabled", new HashSet<>()); + Map accountsDefaultContentTypes=fromJson(prefs.getString("accountsDefaultContentTypes", null), accountsDefaultContentTypesType, new HashMap<>()); + Map> pinnedTimelines=fromJson(prefs.getString("pinnedTimelines", null), pinnedTimelinesType, new HashMap<>()); + Set accountsWithLocalOnlySupport=prefs.getStringSet("accountsWithLocalOnlySupport", new HashSet<>()); + Set accountsInGlitchMode=prefs.getStringSet("accountsInGlitchMode", new HashSet<>()); + Map> recentLanguages=fromJson(prefs.getString("recentLanguages", null), recentLanguagesType, new HashMap<>()); + + for(AccountSession session : asm.getLoggedInAccounts()){ + String accountID=session.getID(); + AccountLocalPreferences localPrefs=session.getLocalPreferences(); + localPrefs.revealCWs=prefs.getBoolean("alwaysExpandContentWarnings", false); + localPrefs.recentLanguages=recentLanguages.get(accountID); + // reset: localPrefs.contentTypesEnabled=accountsWithContentTypesEnabled.contains(accountID); + localPrefs.defaultContentType=accountsDefaultContentTypes.getOrDefault(accountID, ContentType.PLAIN); + localPrefs.showInteractionCounts=prefs.getBoolean("showInteractionCounts", false); + localPrefs.timelines=pinnedTimelines.getOrDefault(accountID, TimelineDefinition.getDefaultTimelines(accountID)); + localPrefs.localOnlySupported=accountsWithLocalOnlySupport.contains(accountID); + localPrefs.glitchInstance=accountsInGlitchMode.contains(accountID); + localPrefs.publishButtonText=prefs.getString("publishButtonText", null); + localPrefs.keepOnlyLatestNotification=prefs.getBoolean("keepOnlyLatestNotification", false); + localPrefs.showReplies=prefs.getBoolean("showReplies", true); + localPrefs.showBoosts=prefs.getBoolean("showBoosts", true); + + if(session.getInstance().map(Instance::isAkkoma).orElse(false)){ + localPrefs.timelineReplyVisibility=prefs.getString("replyVisibility", null); + } + + localPrefs.save(); + } + + prefs.edit().putInt("migrationLevel", 61).apply(); + } + public enum ColorPreference{ MATERIAL3, PINK, @@ -247,8 +313,23 @@ public class GlobalUserPreferences{ RED, YELLOW, NORD, - WHITE - } + WHITE; + + public @StringRes int getName() { + return switch(this){ + case MATERIAL3 -> R.string.sk_color_palette_material3; + case PINK -> R.string.sk_color_palette_pink; + case PURPLE -> R.string.sk_color_palette_purple; + case GREEN -> R.string.sk_color_palette_green; + case BLUE -> R.string.sk_color_palette_blue; + case BROWN -> R.string.sk_color_palette_brown; + case RED -> R.string.sk_color_palette_red; + case YELLOW -> R.string.sk_color_palette_yellow; + case NORD -> R.string.mo_color_palette_nord; + case WHITE -> R.string.mo_color_palette_black_and_white; + }; + } + } public enum ThemePreference{ AUTO, diff --git a/mastodon/src/main/java/org/joinmastodon/android/MainActivity.java b/mastodon/src/main/java/org/joinmastodon/android/MainActivity.java index d2daba4fa..01de7ec85 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/MainActivity.java +++ b/mastodon/src/main/java/org/joinmastodon/android/MainActivity.java @@ -11,6 +11,7 @@ import android.content.Intent; import android.content.pm.PackageManager; import android.graphics.Bitmap; import android.net.Uri; +import android.net.Uri; import android.os.Build; import android.os.Bundle; import android.provider.MediaStore; @@ -20,6 +21,7 @@ import android.widget.FrameLayout; import android.widget.Toast; import org.joinmastodon.android.api.ObjectValidationException; +import org.joinmastodon.android.api.requests.search.GetSearchResults; import org.joinmastodon.android.api.session.AccountSession; import org.joinmastodon.android.api.session.AccountSessionManager; import org.joinmastodon.android.events.TakePictureRequestEvent; @@ -30,6 +32,7 @@ import org.joinmastodon.android.fragments.ThreadFragment; import org.joinmastodon.android.fragments.onboarding.AccountActivationFragment; import org.joinmastodon.android.fragments.onboarding.CustomWelcomeFragment; import org.joinmastodon.android.model.Notification; +import org.joinmastodon.android.model.SearchResults; import org.joinmastodon.android.ui.utils.UiUtils; import org.joinmastodon.android.updater.GithubSelfUpdater; import org.joinmastodon.android.utils.ProvidesAssistContent; @@ -37,6 +40,9 @@ import org.parceler.Parcels; import androidx.annotation.Nullable; import me.grishka.appkit.FragmentStackActivity; +import me.grishka.appkit.Nav; +import me.grishka.appkit.api.Callback; +import me.grishka.appkit.api.ErrorResponse; public class MainActivity extends FragmentStackActivity implements ProvidesAssistContent { @Override @@ -82,6 +88,8 @@ public class MainActivity extends FragmentStackActivity implements ProvidesAssis showFragmentForNotification(notification, session.getID()); } else if (intent.getBooleanExtra("compose", false)){ showCompose(); + } else if (Intent.ACTION_VIEW.equals(intent.getAction())){ + handleURL(intent.getData(), null); } else { showFragmentClearingBackStack(fragment); maybeRequestNotificationsPermission(); @@ -120,11 +128,55 @@ public class MainActivity extends FragmentStackActivity implements ProvidesAssis } }else if(intent.getBooleanExtra("compose", false)){ showCompose(); + }else if(Intent.ACTION_VIEW.equals(intent.getAction())){ + handleURL(intent.getData(), null); }/*else if(intent.hasExtra(PackageInstaller.EXTRA_STATUS) && GithubSelfUpdater.needSelfUpdating()){ GithubSelfUpdater.getInstance().handleIntentFromInstaller(intent, this); }*/ } + public void handleURL(Uri uri, String accountID){ + if(uri==null) + return; + if(!"https".equals(uri.getScheme()) && !"http".equals(uri.getScheme())) + return; + AccountSession session; + if(accountID==null) + session=AccountSessionManager.getInstance().getLastActiveAccount(); + else + session=AccountSessionManager.get(accountID); + if(session==null || !session.activated) + return; + openSearchQuery(uri.toString(), session.getID(), R.string.opening_link, false); + } + + public void openSearchQuery(String q, String accountID, int progressText, boolean fromSearch){ + new GetSearchResults(q, null, true) + .setCallback(new Callback<>(){ + @Override + public void onSuccess(SearchResults result){ + Bundle args=new Bundle(); + args.putString("account", accountID); + if(result.statuses!=null && !result.statuses.isEmpty()){ + args.putParcelable("status", Parcels.wrap(result.statuses.get(0))); + Nav.go(MainActivity.this, ThreadFragment.class, args); + }else if(result.accounts!=null && !result.accounts.isEmpty()){ + args.putParcelable("profileAccount", Parcels.wrap(result.accounts.get(0))); + Nav.go(MainActivity.this, ProfileFragment.class, args); + }else{ + Toast.makeText(MainActivity.this, fromSearch ? R.string.no_search_results : R.string.link_not_supported, Toast.LENGTH_SHORT).show(); + } + } + + @Override + public void onError(ErrorResponse error){ + error.showToast(MainActivity.this); + } + }) + .wrapProgress(this, progressText, true) + .exec(accountID); + } + private void showFragmentForNotification(Notification notification, String accountID){ try{ notification.postprocess(); diff --git a/mastodon/src/main/java/org/joinmastodon/android/MastodonApp.java b/mastodon/src/main/java/org/joinmastodon/android/MastodonApp.java index 8561fd7f2..61cd8deed 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/MastodonApp.java +++ b/mastodon/src/main/java/org/joinmastodon/android/MastodonApp.java @@ -3,6 +3,7 @@ package org.joinmastodon.android; import android.annotation.SuppressLint; import android.app.Application; import android.content.Context; +import android.webkit.WebView; import org.joinmastodon.android.api.PushSubscriptionManager; @@ -28,5 +29,8 @@ public class MastodonApp extends Application{ PushSubscriptionManager.tryRegisterFCM(); GlobalUserPreferences.load(); + if(BuildConfig.DEBUG){ + WebView.setWebContentsDebuggingEnabled(true); + } } } diff --git a/mastodon/src/main/java/org/joinmastodon/android/PushNotificationReceiver.java b/mastodon/src/main/java/org/joinmastodon/android/PushNotificationReceiver.java index fdd3c4504..6844e2e28 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/PushNotificationReceiver.java +++ b/mastodon/src/main/java/org/joinmastodon/android/PushNotificationReceiver.java @@ -17,6 +17,7 @@ import android.graphics.drawable.Drawable; import android.opengl.Visibility; import android.os.Build; import android.os.Bundle; +import android.text.SpannableStringBuilder; import android.text.TextUtils; import android.util.Log; @@ -29,7 +30,6 @@ import org.joinmastodon.android.api.requests.statuses.SetStatusFavorited; import org.joinmastodon.android.api.requests.statuses.SetStatusReblogged; import org.joinmastodon.android.api.session.AccountSession; import org.joinmastodon.android.api.session.AccountSessionManager; -import org.joinmastodon.android.events.NotificationReceivedEvent; import org.joinmastodon.android.model.Account; import org.joinmastodon.android.model.Mention; import org.joinmastodon.android.model.NotificationAction; @@ -38,12 +38,15 @@ import org.joinmastodon.android.model.PushNotification; import org.joinmastodon.android.model.Status; import org.joinmastodon.android.model.StatusPrivacy; import org.joinmastodon.android.model.StatusPrivacy; +import org.joinmastodon.android.ui.text.HtmlParser; import org.joinmastodon.android.ui.utils.UiUtils; import org.parceler.Parcels; import java.util.ArrayList; import java.util.Arrays; +import java.util.HashMap; import java.util.List; +import java.util.Map; import java.util.Random; import java.util.UUID; import java.util.stream.Collectors; @@ -62,6 +65,7 @@ public class PushNotificationReceiver extends BroadcastReceiver{ private static final int SUMMARY_ID = 791; private static int notificationId = 0; + private static final Map notificationIdsForAccounts = new HashMap<>(); @Override public void onReceive(Context context, Intent intent){ @@ -93,9 +97,12 @@ public class PushNotificationReceiver extends BroadcastReceiver{ Log.w(TAG, "onReceive: account for id '"+pushAccountID+"' not found"); return; } + if(account.getLocalPreferences().getNotificationsPauseEndTime()>System.currentTimeMillis()){ + Log.i(TAG, "onReceive: dropping notification because user has paused notifications for this account"); + return; + } String accountID=account.getID(); PushNotification pn=AccountSessionManager.getInstance().getAccount(accountID).getPushSubscriptionManager().decryptNotification(k, p, s); - E.post(new NotificationReceivedEvent(accountID, pn.notificationId+"")); new GetNotificationByID(pn.notificationId+"") .setCallback(new Callback<>(){ @Override @@ -128,24 +135,16 @@ public class PushNotificationReceiver extends BroadcastReceiver{ if(intent.hasExtra("notification")){ org.joinmastodon.android.model.Notification notification=Parcels.unwrap(intent.getParcelableExtra("notification")); - String statusID = null; - String targetAccountID = null; - - if(notification.status != null){ - statusID = notification.status.id; - } - if(notification.account != null){ - targetAccountID = notification.account.id; - } - if (statusID != null || targetAccountID != null) { + String statusID=notification.status.id; + if (statusID != null) { AccountSessionManager accountSessionManager = AccountSessionManager.getInstance(); Preferences preferences = accountSessionManager.getAccount(accountID).preferences; switch (NotificationAction.values()[intent.getIntExtra("notificationAction", 0)]) { case FAVORITE -> new SetStatusFavorited(statusID, true).exec(accountID); case BOOKMARK -> new SetStatusBookmarked(statusID, true).exec(accountID); - case BOOST -> new SetStatusReblogged(notification.status.id, true, preferences.postingDefaultVisibility).exec(accountID); - case UNBOOST -> new SetStatusReblogged(notification.status.id, false, preferences.postingDefaultVisibility).exec(accountID); + case REBLOG -> new SetStatusReblogged(notification.status.id, true, preferences.postingDefaultVisibility).exec(accountID); + case UNDO_REBLOG -> new SetStatusReblogged(notification.status.id, false, preferences.postingDefaultVisibility).exec(accountID); case REPLY -> handleReplyAction(context, accountID, intent, notification, notificationId, preferences); case FOLLOW_BACK -> new SetAccountFollowed(notification.account.id, true, true, false).exec(accountID); default -> Log.w(TAG, "onReceive: Failed to get NotificationAction"); @@ -157,10 +156,15 @@ public class PushNotificationReceiver extends BroadcastReceiver{ } } + public void notifyUnifiedPush(Context context, String accountID, org.joinmastodon.android.model.Notification notification) { + // push notifications are only created from the official push notification, so we create a fake from by transforming the notification + PushNotificationReceiver.this.notify(context, PushNotification.fromNotification(context, notification), accountID, notification); + } + private void notify(Context context, PushNotification pn, String accountID, org.joinmastodon.android.model.Notification notification){ NotificationManager nm=context.getSystemService(NotificationManager.class); - notificationId=getPrefs().getInt("latestNotificationId", 0); - Account self=AccountSessionManager.getInstance().getAccount(accountID).self; + AccountSession session=AccountSessionManager.get(accountID); + Account self=session.self; String accountName="@"+self.username+"@"+AccountSessionManager.getInstance().getAccount(accountID).domain; Notification.Builder builder; if(Build.VERSION.SDK_INT>=Build.VERSION_CODES.O){ @@ -230,8 +234,21 @@ public class PushNotificationReceiver extends BroadcastReceiver{ builder.setSubText(accountName); } - int id = GlobalUserPreferences.keepOnlyLatestNotification ? NOTIFICATION_ID : notificationId++; - getPrefs().edit().putInt("latestNotificationId", notificationId).apply(); + int id; + if(session.getLocalPreferences().keepOnlyLatestNotification){ + if(notificationIdsForAccounts.containsKey(accountID)){ + // we overwrite the existing notification + id=notificationIdsForAccounts.get(accountID); + }else{ + // there's no existing notification, so we increment + id=notificationId++; + // and store the notification id for this account + notificationIdsForAccounts.put(accountID, id); + } + }else{ + // we don't want to overwrite anything, therefore incrementing + id=notificationId++; + } if (notification != null){ switch (pn.notificationType){ diff --git a/mastodon/src/main/java/org/joinmastodon/android/UnifiedPushNotificationReceiver.java b/mastodon/src/main/java/org/joinmastodon/android/UnifiedPushNotificationReceiver.java new file mode 100644 index 000000000..3d7b0d3a9 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/UnifiedPushNotificationReceiver.java @@ -0,0 +1,81 @@ +package org.joinmastodon.android; + +import android.content.Context; +import android.util.Log; + +import org.jetbrains.annotations.NotNull; +import org.joinmastodon.android.api.MastodonAPIController; +import org.joinmastodon.android.api.session.AccountSession; +import org.joinmastodon.android.api.session.AccountSessionManager; +import org.joinmastodon.android.model.Notification; +import org.joinmastodon.android.model.PaginatedResponse; +import org.unifiedpush.android.connector.MessagingReceiver; + +import java.util.List; + +import me.grishka.appkit.api.Callback; +import me.grishka.appkit.api.ErrorResponse; + +public class UnifiedPushNotificationReceiver extends MessagingReceiver{ + private static final String TAG="UnifiedPushNotificationReceiver"; + + public UnifiedPushNotificationReceiver() { + super(); + } + + @Override + public void onNewEndpoint(@NotNull Context context, @NotNull String endpoint, @NotNull String instance) { + // Called when a new endpoint be used for sending push messages + Log.d(TAG, "onNewEndpoint: New Endpoint " + endpoint + " for "+ instance); + AccountSession account = AccountSessionManager.getInstance().getLastActiveAccount(); + if (account != null) + account.getPushSubscriptionManager().registerAccountForPush(null); + } + + @Override + public void onRegistrationFailed(@NotNull Context context, @NotNull String instance) { + // called when the registration is not possible, eg. no network + Log.d(TAG, "onRegistrationFailed: " + instance); + //re-register for gcm + AccountSession account = AccountSessionManager.getInstance().getLastActiveAccount(); + if (account != null) + account.getPushSubscriptionManager().registerAccountForPush(null); + } + + @Override + public void onUnregistered(@NotNull Context context, @NotNull String instance) { + // called when this application is unregistered from receiving push messages + Log.d(TAG, "onUnregistered: " + instance); + //re-register for gcm + AccountSession account = AccountSessionManager.getInstance().getLastActiveAccount(); + if (account != null) + account.getPushSubscriptionManager().registerAccountForPush(null); + } + + @Override + public void onMessage(@NotNull Context context, @NotNull byte[] message, @NotNull String instance) { + // Called when a new message is received. The message contains the full POST body of the push message + AccountSession account = AccountSessionManager.getInstance().getAccount(instance); + + //this is stupid + // Mastodon stores the info to decrypt the message in the HTTP headers, which are not accessible in UnifiedPush, + // thus it is not possible to decrypt them. SO we need to re-request them from the server and transform them later on + // The official uses fcm and moves the headers to extra data, see + // https://github.com/mastodon/webpush-fcm-relay/blob/cac95b28d5364b0204f629283141ac3fb749e0c5/webpush-fcm-relay.go#L116 + // https://github.com/tuskyapp/Tusky/pull/2303#issue-1112080540 + account.getCacheController().getNotifications(null, 1, false, false, true, new Callback<>(){ + @Override + public void onSuccess(PaginatedResponse> result){ + result.items + .stream() + .findFirst() + .ifPresent(value->MastodonAPIController.runInBackground(()->new PushNotificationReceiver().notifyUnifiedPush(context, instance, value))); + } + + @Override + public void onError(ErrorResponse error){ + //professional error handling + } + }); + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/CacheController.java b/mastodon/src/main/java/org/joinmastodon/android/api/CacheController.java index 3cab9c71f..79cf1c680 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/api/CacheController.java +++ b/mastodon/src/main/java/org/joinmastodon/android/api/CacheController.java @@ -13,22 +13,20 @@ import org.joinmastodon.android.BuildConfig; import org.joinmastodon.android.MastodonApp; import org.joinmastodon.android.api.requests.notifications.GetNotifications; import org.joinmastodon.android.api.requests.timelines.GetHomeTimeline; -import org.joinmastodon.android.api.session.AccountSession; import org.joinmastodon.android.api.session.AccountSessionManager; import org.joinmastodon.android.model.CacheablePaginatedResponse; -import org.joinmastodon.android.model.Filter; +import org.joinmastodon.android.model.FilterContext; import org.joinmastodon.android.model.Instance; import org.joinmastodon.android.model.Notification; +import org.joinmastodon.android.model.PaginatedResponse; import org.joinmastodon.android.model.SearchResult; import org.joinmastodon.android.model.Status; -import org.joinmastodon.android.utils.StatusFilterPredicate; import java.io.IOException; import java.util.ArrayList; import java.util.EnumSet; import java.util.List; import java.util.function.Consumer; -import java.util.stream.Collectors; import me.grishka.appkit.api.Callback; import me.grishka.appkit.api.ErrorResponse; @@ -43,6 +41,8 @@ public class CacheController{ private final String accountID; private DatabaseHelper db; private final Runnable databaseCloseRunnable=this::closeDatabase; + private boolean loadingNotifications; + private final ArrayList>>> pendingNotificationsCallbacks=new ArrayList<>(); private static final int POST_FLAG_GAP_AFTER=1; @@ -58,7 +58,6 @@ public class CacheController{ cancelDelayedClose(); databaseThread.postRunnable(()->{ try{ - List filters=AccountSessionManager.getInstance().getAccount(accountID).wordFilters.stream().filter(f->f.context.contains(Filter.FilterContext.HOME)).collect(Collectors.toList()); if(!forceReload){ SQLiteDatabase db=getOrOpenDatabase(); try(Cursor cursor=db.query("home_timeline", new String[]{"json", "flags"}, maxID==null ? null : "`id` result=new ArrayList<>(); cursor.moveToFirst(); String newMaxID; - outer: do{ Status status=MastodonAPIController.gson.fromJson(cursor.getString(0), Status.class); status.postprocess(); int flags=cursor.getInt(1); status.hasGapAfter=((flags & POST_FLAG_GAP_AFTER)!=0); newMaxID=status.id; - if (!new StatusFilterPredicate(filters, Filter.FilterContext.HOME).test(status)) - continue outer; result.add(status); }while(cursor.moveToNext()); String _newMaxID=newMaxID; + AccountSessionManager.get(accountID).filterStatuses(result, FilterContext.HOME); uiHandler.post(()->callback.onSuccess(new CacheablePaginatedResponse<>(result, _newMaxID, true))); return; } @@ -85,11 +82,13 @@ public class CacheController{ Log.w(TAG, "getHomeTimeline: corrupted status object in database", x); } } - new GetHomeTimeline(maxID, null, count, null) + new GetHomeTimeline(maxID, null, count, null, AccountSessionManager.get(accountID).getLocalPreferences().timelineReplyVisibility) .setCallback(new Callback<>(){ @Override public void onSuccess(List result){ - callback.onSuccess(new CacheablePaginatedResponse<>(result.stream().filter(new StatusFilterPredicate(filters, Filter.FilterContext.HOME)).collect(Collectors.toList()), result.isEmpty() ? null : result.get(result.size()-1).id, false)); + ArrayList filtered=new ArrayList<>(result); + AccountSessionManager.get(accountID).filterStatuses(filtered, FilterContext.HOME); + callback.onSuccess(new CacheablePaginatedResponse<>(filtered, result.isEmpty() ? null : result.get(result.size()-1).id, false)); putHomeTimeline(result, maxID==null); } @@ -126,12 +125,39 @@ public class CacheController{ }); } - public void getNotifications(String maxID, int count, boolean onlyMentions, boolean onlyPosts, boolean forceReload, Callback>> callback){ + public void updateStatus(Status status) { + runOnDbThread((db)->{ + ContentValues statusUpdate=new ContentValues(1); + statusUpdate.put("json", MastodonAPIController.gson.toJson(status)); + db.update("home_timeline", statusUpdate, "id = ?", new String[] { status.id }); + }); + } + + public void updateNotification(Notification notification) { + runOnDbThread((db)->{ + ContentValues notificationUpdate=new ContentValues(1); + notificationUpdate.put("json", MastodonAPIController.gson.toJson(notification)); + String[] notificationArgs = new String[] { notification.id }; + db.update("notifications_all", notificationUpdate, "id = ?", notificationArgs); + db.update("notifications_mentions", notificationUpdate, "id = ?", notificationArgs); + db.update("notifications_posts", notificationUpdate, "id = ?", notificationArgs); + + ContentValues statusUpdate=new ContentValues(1); + statusUpdate.put("json", MastodonAPIController.gson.toJson(notification.status)); + db.update("home_timeline", statusUpdate, "id = ?", new String[] { notification.status.id }); + }); + } + + public void getNotifications(String maxID, int count, boolean onlyMentions, boolean onlyPosts, boolean forceReload, Callback>> callback){ cancelDelayedClose(); databaseThread.postRunnable(()->{ try{ - AccountSession accountSession=AccountSessionManager.getInstance().getAccount(accountID); - List filters=accountSession.wordFilters.stream().filter(f->f.context.contains(Filter.FilterContext.NOTIFICATIONS)).collect(Collectors.toList()); + if(!onlyMentions && !onlyPosts && loadingNotifications){ + synchronized(pendingNotificationsCallbacks){ + pendingNotificationsCallbacks.add(callback); + } + return; + } if(!forceReload){ SQLiteDatabase db=getOrOpenDatabase(); String table=onlyPosts ? "notifications_posts" : onlyMentions ? "notifications_mentions" : "notifications_all"; @@ -140,42 +166,56 @@ public class CacheController{ ArrayList result=new ArrayList<>(); cursor.moveToFirst(); String newMaxID; - outer: do{ Notification ntf=MastodonAPIController.gson.fromJson(cursor.getString(0), Notification.class); ntf.postprocess(); newMaxID=ntf.id; - if(ntf.status!=null){ - if (!new StatusFilterPredicate(filters, Filter.FilterContext.NOTIFICATIONS).test(ntf.status)) - continue outer; - } result.add(ntf); }while(cursor.moveToNext()); String _newMaxID=newMaxID; - uiHandler.post(()->callback.onSuccess(new CacheablePaginatedResponse<>(result, _newMaxID, true))); + AccountSessionManager.get(accountID).filterStatusContainingObjects(result, n->n.status, FilterContext.NOTIFICATIONS); + uiHandler.post(()->callback.onSuccess(new PaginatedResponse<>(result, _newMaxID))); return; } }catch(IOException x){ Log.w(TAG, "getNotifications: corrupted notification object in database", x); } } - Instance instance=AccountSessionManager.getInstance().getInstanceInfo(accountSession.domain); - new GetNotifications(maxID, count, onlyPosts ? EnumSet.of(Notification.Type.STATUS) : onlyMentions ? EnumSet.of(Notification.Type.MENTION): EnumSet.allOf(Notification.Type.class), instance.isAkkoma()) + if(!onlyMentions && !onlyPosts) + loadingNotifications=true; + boolean isAkkoma = AccountSessionManager.get(accountID).getInstance().map(Instance::isAkkoma).orElse(false); + new GetNotifications(maxID, count, onlyPosts ? EnumSet.of(Notification.Type.STATUS) : onlyMentions ? EnumSet.of(Notification.Type.MENTION): EnumSet.allOf(Notification.Type.class), isAkkoma) .setCallback(new Callback<>(){ @Override public void onSuccess(List result){ - callback.onSuccess(new CacheablePaginatedResponse<>(result.stream().filter(ntf->{ - if(ntf.status!=null){ - return new StatusFilterPredicate(filters, Filter.FilterContext.NOTIFICATIONS).test(ntf.status); - } - return true; - }).collect(Collectors.toList()), result.isEmpty() ? null : result.get(result.size()-1).id, false)); + ArrayList filtered=new ArrayList<>(result); + AccountSessionManager.get(accountID).filterStatusContainingObjects(filtered, n->n.status, FilterContext.NOTIFICATIONS); + PaginatedResponse> res=new PaginatedResponse<>(filtered, result.isEmpty() ? null : result.get(result.size()-1).id); + callback.onSuccess(res); putNotifications(result, onlyMentions, onlyPosts, maxID==null); + if(!onlyMentions){ + loadingNotifications=false; + synchronized(pendingNotificationsCallbacks){ + for(Callback>> cb:pendingNotificationsCallbacks){ + cb.onSuccess(res); + } + pendingNotificationsCallbacks.clear(); + } + } } @Override public void onError(ErrorResponse error){ callback.onError(error); + if(!onlyMentions){ + loadingNotifications=false; + synchronized(pendingNotificationsCallbacks){ + for(Callback>> cb:pendingNotificationsCallbacks){ + cb.onError(error); + } + pendingNotificationsCallbacks.clear(); + } + } } }) .exec(accountID); @@ -327,7 +367,7 @@ public class CacheController{ createRecentSearchesTable(db); } if(oldVersion<3){ - // MEGALODON-SPECIFIC + // MEGALODON createPostsNotificationsTable(db); } if(oldVersion<4){ diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/MastodonAPIController.java b/mastodon/src/main/java/org/joinmastodon/android/api/MastodonAPIController.java index 7bd1df547..d144c3c34 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/api/MastodonAPIController.java +++ b/mastodon/src/main/java/org/joinmastodon/android/api/MastodonAPIController.java @@ -117,6 +117,9 @@ public class MastodonAPIController{ synchronized(req){ req.okhttpCall=call; } + if(req.timeout>0){ + call.timeout().timeout(req.timeout, TimeUnit.MILLISECONDS); + } if(BuildConfig.DEBUG) Log.d(TAG, "["+(session==null ? "no-auth" : session.getID())+"] Sending request: "+hreq); @@ -153,13 +156,17 @@ public class MastodonAPIController{ Log.d(TAG, "["+(session==null ? "no-auth" : session.getID())+"] response body: "+respJson); if(req.respTypeToken!=null) respObj=gson.fromJson(respJson, req.respTypeToken.getType()); - else + else if(req.respClass!=null) respObj=gson.fromJson(respJson, req.respClass); + else + respObj=null; }else{ if(req.respTypeToken!=null) respObj=gson.fromJson(reader, req.respTypeToken.getType()); - else + else if(req.respClass!=null) respObj=gson.fromJson(reader, req.respClass); + else + respObj=null; } }catch(JsonIOException|JsonSyntaxException x){ if (req.context != null && response.body().contentType().subtype().equals("html")) { diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/MastodonAPIRequest.java b/mastodon/src/main/java/org/joinmastodon/android/api/MastodonAPIRequest.java index b6d624588..023c28f21 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/api/MastodonAPIRequest.java +++ b/mastodon/src/main/java/org/joinmastodon/android/api/MastodonAPIRequest.java @@ -49,6 +49,7 @@ public abstract class MastodonAPIRequest extends APIRequest{ Token token; boolean canceled, isRemote; Map headers; + long timeout; private ProgressDialog progressDialog; protected boolean removeUnsupportedItems; @Nullable Context context; @@ -117,16 +118,16 @@ public abstract class MastodonAPIRequest extends APIRequest{ .findAny()) .map(AccountSession::getID) .map(this::exec) - .orElse(this.execNoAuth(domain)); + .orElseGet(() -> this.execNoAuth(domain)); } - public MastodonAPIRequest wrapProgress(Activity activity, @StringRes int message, boolean cancelable){ - return wrapProgress(activity, message, cancelable, null); + public MastodonAPIRequest wrapProgress(Context context, @StringRes int message, boolean cancelable){ + return wrapProgress(context, message, cancelable, null); } - public MastodonAPIRequest wrapProgress(Activity activity, @StringRes int message, boolean cancelable, Consumer transform){ - progressDialog=new ProgressDialog(activity); - progressDialog.setMessage(activity.getString(message)); + public MastodonAPIRequest wrapProgress(Context context, @StringRes int message, boolean cancelable, Consumer transform){ + progressDialog=new ProgressDialog(context); + progressDialog.setMessage(context.getString(message)); progressDialog.setCancelable(cancelable); if (transform != null) transform.accept(progressDialog); if(cancelable){ @@ -152,6 +153,10 @@ public abstract class MastodonAPIRequest extends APIRequest{ headers.put(key, value); } + protected void setTimeout(long timeout){ + this.timeout=timeout; + } + protected String getPathPrefix(){ return "/api/v1"; } diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/PushSubscriptionManager.java b/mastodon/src/main/java/org/joinmastodon/android/api/PushSubscriptionManager.java index 84dd040d2..4633eec64 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/api/PushSubscriptionManager.java +++ b/mastodon/src/main/java/org/joinmastodon/android/api/PushSubscriptionManager.java @@ -87,7 +87,6 @@ public class PushSubscriptionManager{ private String accountID; private PrivateKey privateKey; private PublicKey publicKey; - private PublicKey serverKey; private byte[] authKey; public PushSubscriptionManager(String accountID){ @@ -121,9 +120,22 @@ public class PushSubscriptionManager{ return !TextUtils.isEmpty(deviceToken); } + public void registerAccountForPush(PushSubscription subscription){ + // this function is used for registering push notifications using FCM + // to avoid NonFreeNet in F-Droid, this registration is disabled in it + // see https://github.com/LucasGGamerM/moshidon/issues/206 for more context + if(BuildConfig.BUILD_TYPE.equals("fdroidRelease")) + return; + if(TextUtils.isEmpty(deviceToken)) throw new IllegalStateException("No device push token available"); + String endpoint = "https://app.joinmastodon.org/relay-to/fcm/"+deviceToken+"/"+accountID; + registerAccountForPush(subscription, endpoint); + } + + public void registerAccountForPush(PushSubscription subscription, String endpoint){ + MastodonAPIController.runInBackground(()->{ Log.d(TAG, "registerAccountForPush: started for "+accountID); String encodedPublicKey, encodedAuthKey, pushAccountID; @@ -152,20 +164,15 @@ public class PushSubscriptionManager{ Log.e(TAG, "registerAccountForPush: error generating encryption key", e); return; } - new RegisterForPushNotifications(deviceToken, + new RegisterForPushNotifications(endpoint, encodedPublicKey, encodedAuthKey, subscription==null ? PushSubscription.Alerts.ofAll() : subscription.alerts, - subscription==null ? PushSubscription.Policy.ALL : subscription.policy, - pushAccountID) + subscription==null ? PushSubscription.Policy.ALL : subscription.policy) .setCallback(new Callback<>(){ @Override public void onSuccess(PushSubscription result){ MastodonAPIController.runInBackground(()->{ - result.serverKey=result.serverKey.replace('/','_'); - result.serverKey=result.serverKey.replace('+','-'); - serverKey=deserializeRawPublicKey(Base64.decode(result.serverKey, Base64.URL_SAFE)); - AccountSession session=AccountSessionManager.getInstance().tryGetAccount(accountID); if(session==null) return; diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/ResultlessMastodonAPIRequest.java b/mastodon/src/main/java/org/joinmastodon/android/api/ResultlessMastodonAPIRequest.java new file mode 100644 index 000000000..26900f063 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/api/ResultlessMastodonAPIRequest.java @@ -0,0 +1,9 @@ +package org.joinmastodon.android.api; + +import com.google.gson.reflect.TypeToken; + +public abstract class ResultlessMastodonAPIRequest extends MastodonAPIRequest{ + public ResultlessMastodonAPIRequest(HttpMethod method, String path){ + super(method, path, (Class)null); + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/requests/accounts/GetAccountFeaturedHashtags.java b/mastodon/src/main/java/org/joinmastodon/android/api/requests/accounts/GetAccountFeaturedHashtags.java new file mode 100644 index 000000000..cac75472b --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/api/requests/accounts/GetAccountFeaturedHashtags.java @@ -0,0 +1,14 @@ +package org.joinmastodon.android.api.requests.accounts; + +import com.google.gson.reflect.TypeToken; + +import org.joinmastodon.android.api.MastodonAPIRequest; +import org.joinmastodon.android.model.Hashtag; + +import java.util.List; + +public class GetAccountFeaturedHashtags extends MastodonAPIRequest>{ + public GetAccountFeaturedHashtags(String id){ + super(HttpMethod.GET, "/accounts/"+id+"/featured_tags", new TypeToken<>(){}); + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/requests/accounts/GetAccountStatuses.java b/mastodon/src/main/java/org/joinmastodon/android/api/requests/accounts/GetAccountStatuses.java index d1e8553ff..0b8271d59 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/api/requests/accounts/GetAccountStatuses.java +++ b/mastodon/src/main/java/org/joinmastodon/android/api/requests/accounts/GetAccountStatuses.java @@ -21,22 +21,22 @@ public class GetAccountStatuses extends MastodonAPIRequest>{ switch(filter){ case DEFAULT -> addQueryParameter("exclude_replies", "true"); case INCLUDE_REPLIES -> {} - case PINNED -> addQueryParameter("pinned", "true"); case MEDIA -> addQueryParameter("only_media", "true"); case NO_REBLOGS -> { addQueryParameter("exclude_replies", "true"); addQueryParameter("exclude_reblogs", "true"); } case OWN_POSTS_AND_REPLIES -> addQueryParameter("exclude_reblogs", "true"); + case PINNED -> addQueryParameter("pinned", "true"); } } public enum Filter{ DEFAULT, INCLUDE_REPLIES, - PINNED, MEDIA, NO_REBLOGS, - OWN_POSTS_AND_REPLIES + OWN_POSTS_AND_REPLIES, + PINNED } } diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/requests/accounts/RegisterAccount.java b/mastodon/src/main/java/org/joinmastodon/android/api/requests/accounts/RegisterAccount.java index 7656d6d4c..622145237 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/api/requests/accounts/RegisterAccount.java +++ b/mastodon/src/main/java/org/joinmastodon/android/api/requests/accounts/RegisterAccount.java @@ -4,21 +4,22 @@ import org.joinmastodon.android.api.MastodonAPIRequest; import org.joinmastodon.android.model.Token; public class RegisterAccount extends MastodonAPIRequest{ - public RegisterAccount(String username, String email, String password, String locale, String reason){ + public RegisterAccount(String username, String email, String password, String locale, String reason, String timezone){ super(HttpMethod.POST, "/accounts", Token.class); - setRequestBody(new Body(username, email, password, locale, reason)); + setRequestBody(new Body(username, email, password, locale, reason, timezone)); } private static class Body{ - public String username, email, password, locale, reason; + public String username, email, password, locale, reason, timeZone; public boolean agreement=true; - public Body(String username, String email, String password, String locale, String reason){ + public Body(String username, String email, String password, String locale, String reason, String timeZone){ this.username=username; this.email=email; this.password=password; this.locale=locale; this.reason=reason; + this.timeZone=timeZone; } } } diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/requests/accounts/SetAccountMuted.java b/mastodon/src/main/java/org/joinmastodon/android/api/requests/accounts/SetAccountMuted.java index d6b91b0fa..7d6afadf6 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/api/requests/accounts/SetAccountMuted.java +++ b/mastodon/src/main/java/org/joinmastodon/android/api/requests/accounts/SetAccountMuted.java @@ -6,7 +6,7 @@ import org.joinmastodon.android.model.Relationship; public class SetAccountMuted extends MastodonAPIRequest{ public SetAccountMuted(String id, boolean muted, long duration){ super(HttpMethod.POST, "/accounts/"+id+"/"+(muted ? "mute" : "unmute"), Relationship.class); - setRequestBody(muted ? new Request(duration): new Object()); + setRequestBody(new Request(duration)); } private static class Request{ diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/requests/accounts/UpdateAccountCredentialsPreferences.java b/mastodon/src/main/java/org/joinmastodon/android/api/requests/accounts/UpdateAccountCredentialsPreferences.java new file mode 100644 index 000000000..686b64e3f --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/api/requests/accounts/UpdateAccountCredentialsPreferences.java @@ -0,0 +1,34 @@ +package org.joinmastodon.android.api.requests.accounts; + +import org.joinmastodon.android.api.MastodonAPIRequest; +import org.joinmastodon.android.model.Account; +import org.joinmastodon.android.model.Preferences; +import org.joinmastodon.android.model.StatusPrivacy; + +public class UpdateAccountCredentialsPreferences extends MastodonAPIRequest{ + public UpdateAccountCredentialsPreferences(Preferences preferences, Boolean locked, Boolean discoverable){ + super(HttpMethod.PATCH, "/accounts/update_credentials", Account.class); + setRequestBody(new Request(locked, discoverable, new RequestSource(preferences.postingDefaultVisibility, preferences.postingDefaultLanguage))); + } + + private static class Request{ + public Boolean locked, discoverable; + public RequestSource source; + + public Request(Boolean locked, Boolean discoverable, RequestSource source){ + this.locked=locked; + this.discoverable=discoverable; + this.source=source; + } + } + + private static class RequestSource{ + public StatusPrivacy privacy; + public String language; + + public RequestSource(StatusPrivacy privacy, String language){ + this.privacy=privacy; + this.language=language; + } + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/requests/catalog/GetCatalogDefaultInstances.java b/mastodon/src/main/java/org/joinmastodon/android/api/requests/catalog/GetCatalogDefaultInstances.java new file mode 100644 index 000000000..238ce2d84 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/api/requests/catalog/GetCatalogDefaultInstances.java @@ -0,0 +1,22 @@ +package org.joinmastodon.android.api.requests.catalog; + +import android.net.Uri; + +import com.google.gson.reflect.TypeToken; + +import org.joinmastodon.android.api.MastodonAPIRequest; +import org.joinmastodon.android.model.catalog.CatalogDefaultInstance; + +import java.util.List; + +public class GetCatalogDefaultInstances extends MastodonAPIRequest>{ + public GetCatalogDefaultInstances(){ + super(HttpMethod.GET, null, new TypeToken<>(){}); + setTimeout(500); + } + + @Override + public Uri getURL(){ + return Uri.parse("https://api.joinmastodon.org/default-servers"); + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/requests/filters/CreateFilter.java b/mastodon/src/main/java/org/joinmastodon/android/api/requests/filters/CreateFilter.java new file mode 100644 index 000000000..ac0477a1d --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/api/requests/filters/CreateFilter.java @@ -0,0 +1,23 @@ +package org.joinmastodon.android.api.requests.filters; + +import org.joinmastodon.android.api.MastodonAPIRequest; +import org.joinmastodon.android.model.Filter; +import org.joinmastodon.android.model.FilterAction; +import org.joinmastodon.android.model.FilterContext; +import org.joinmastodon.android.model.FilterKeyword; + +import java.util.EnumSet; +import java.util.List; +import java.util.stream.Collectors; + +public class CreateFilter extends MastodonAPIRequest{ + public CreateFilter(String title, EnumSet context, FilterAction action, int expiresIn, List words){ + super(HttpMethod.POST, "/filters", Filter.class); + setRequestBody(new FilterRequest(title, context, action, expiresIn==0 ? null : expiresIn, words.stream().map(w->new KeywordAttribute(null, null, w.keyword, w.wholeWord)).collect(Collectors.toList()))); + } + + @Override + protected String getPathPrefix(){ + return "/api/v2"; + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/requests/filters/DeleteFilter.java b/mastodon/src/main/java/org/joinmastodon/android/api/requests/filters/DeleteFilter.java new file mode 100644 index 000000000..6c5400a80 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/api/requests/filters/DeleteFilter.java @@ -0,0 +1,14 @@ +package org.joinmastodon.android.api.requests.filters; + +import org.joinmastodon.android.api.ResultlessMastodonAPIRequest; + +public class DeleteFilter extends ResultlessMastodonAPIRequest{ + public DeleteFilter(String id){ + super(HttpMethod.DELETE, "/filters/"+id); + } + + @Override + protected String getPathPrefix(){ + return "/api/v2"; + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/requests/filters/FilterRequest.java b/mastodon/src/main/java/org/joinmastodon/android/api/requests/filters/FilterRequest.java new file mode 100644 index 000000000..ff61d536f --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/api/requests/filters/FilterRequest.java @@ -0,0 +1,23 @@ +package org.joinmastodon.android.api.requests.filters; + +import org.joinmastodon.android.model.FilterAction; +import org.joinmastodon.android.model.FilterContext; + +import java.util.EnumSet; +import java.util.List; + +class FilterRequest{ + public String title; + public EnumSet context; + public FilterAction filterAction; + public Integer expiresIn; + public List keywordsAttributes; + + public FilterRequest(String title, EnumSet context, FilterAction filterAction, Integer expiresIn, List keywordsAttributes){ + this.title=title; + this.context=context; + this.filterAction=filterAction; + this.expiresIn=expiresIn; + this.keywordsAttributes=keywordsAttributes; + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/requests/accounts/GetWordFilters.java b/mastodon/src/main/java/org/joinmastodon/android/api/requests/filters/GetFilters.java similarity index 52% rename from mastodon/src/main/java/org/joinmastodon/android/api/requests/accounts/GetWordFilters.java rename to mastodon/src/main/java/org/joinmastodon/android/api/requests/filters/GetFilters.java index 781035959..904d42f9e 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/api/requests/accounts/GetWordFilters.java +++ b/mastodon/src/main/java/org/joinmastodon/android/api/requests/filters/GetFilters.java @@ -1,4 +1,4 @@ -package org.joinmastodon.android.api.requests.accounts; +package org.joinmastodon.android.api.requests.filters; import com.google.gson.reflect.TypeToken; @@ -7,8 +7,13 @@ import org.joinmastodon.android.model.Filter; import java.util.List; -public class GetWordFilters extends MastodonAPIRequest>{ - public GetWordFilters(){ +public class GetFilters extends MastodonAPIRequest>{ + public GetFilters(){ super(HttpMethod.GET, "/filters", new TypeToken<>(){}); } + + @Override + protected String getPathPrefix(){ + return "/api/v2"; + } } diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/requests/filters/GetLegacyFilters.java b/mastodon/src/main/java/org/joinmastodon/android/api/requests/filters/GetLegacyFilters.java new file mode 100644 index 000000000..eeefe13a7 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/api/requests/filters/GetLegacyFilters.java @@ -0,0 +1,14 @@ +package org.joinmastodon.android.api.requests.filters; + +import com.google.gson.reflect.TypeToken; + +import org.joinmastodon.android.api.MastodonAPIRequest; +import org.joinmastodon.android.model.LegacyFilter; + +import java.util.List; + +public class GetLegacyFilters extends MastodonAPIRequest>{ + public GetLegacyFilters(){ + super(HttpMethod.GET, "/filters", new TypeToken<>(){}); + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/requests/filters/KeywordAttribute.java b/mastodon/src/main/java/org/joinmastodon/android/api/requests/filters/KeywordAttribute.java new file mode 100644 index 000000000..d35a0f0fa --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/api/requests/filters/KeywordAttribute.java @@ -0,0 +1,18 @@ +package org.joinmastodon.android.api.requests.filters; + +import com.google.gson.annotations.SerializedName; + +class KeywordAttribute{ + public String id; + @SerializedName("_destroy") + public Boolean delete; + public String keyword; + public Boolean wholeWord; + + public KeywordAttribute(String id, Boolean delete, String keyword, Boolean wholeWord){ + this.id=id; + this.delete=delete; + this.keyword=keyword; + this.wholeWord=wholeWord; + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/requests/filters/UpdateFilter.java b/mastodon/src/main/java/org/joinmastodon/android/api/requests/filters/UpdateFilter.java new file mode 100644 index 000000000..2c296540f --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/api/requests/filters/UpdateFilter.java @@ -0,0 +1,30 @@ +package org.joinmastodon.android.api.requests.filters; + +import org.joinmastodon.android.api.MastodonAPIRequest; +import org.joinmastodon.android.model.Filter; +import org.joinmastodon.android.model.FilterAction; +import org.joinmastodon.android.model.FilterContext; +import org.joinmastodon.android.model.FilterKeyword; + +import java.util.EnumSet; +import java.util.List; +import java.util.function.Function; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +public class UpdateFilter extends MastodonAPIRequest{ + public UpdateFilter(String id, String title, EnumSet context, FilterAction action, int expiresIn, List words, List deletedWords){ + super(HttpMethod.PUT, "/filters/"+id, Filter.class); + + List attrs=Stream.of( + words.stream().map(w->new KeywordAttribute(w.id, null, w.keyword, w.wholeWord)), + deletedWords.stream().map(wid->new KeywordAttribute(wid, true, null, null)) + ).flatMap(Function.identity()).collect(Collectors.toList()); + setRequestBody(new FilterRequest(title, context, action, expiresIn==0 ? null : expiresIn, attrs)); + } + + @Override + protected String getPathPrefix(){ + return "/api/v2"; + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/requests/instance/GetInstanceExtendedDescription.java b/mastodon/src/main/java/org/joinmastodon/android/api/requests/instance/GetInstanceExtendedDescription.java new file mode 100644 index 000000000..3d0487e89 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/api/requests/instance/GetInstanceExtendedDescription.java @@ -0,0 +1,16 @@ +package org.joinmastodon.android.api.requests.instance; + +import org.joinmastodon.android.api.MastodonAPIRequest; + +import java.time.Instant; + +public class GetInstanceExtendedDescription extends MastodonAPIRequest{ + public GetInstanceExtendedDescription(){ + super(HttpMethod.GET, "/instance/extended_description", Response.class); + } + + public static class Response{ + public Instant updatedAt; + public String content; + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/requests/markers/GetMarkers.java b/mastodon/src/main/java/org/joinmastodon/android/api/requests/markers/GetMarkers.java index b7dd6536b..644665bae 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/api/requests/markers/GetMarkers.java +++ b/mastodon/src/main/java/org/joinmastodon/android/api/requests/markers/GetMarkers.java @@ -1,17 +1,12 @@ package org.joinmastodon.android.api.requests.markers; -import org.joinmastodon.android.api.ApiUtils; import org.joinmastodon.android.api.MastodonAPIRequest; -import org.joinmastodon.android.model.Marker; -import org.joinmastodon.android.model.Markers; +import org.joinmastodon.android.model.TimelineMarkers; -import java.util.EnumSet; - -public class GetMarkers extends MastodonAPIRequest { - public GetMarkers(EnumSet timelines) { - super(HttpMethod.GET, "/markers", Markers.class); - for (String type : ApiUtils.enumSetToStrings(timelines, Marker.Type.class)){ - addQueryParameter("timeline[]", type); - } +public class GetMarkers extends MastodonAPIRequest{ + public GetMarkers(){ + super(HttpMethod.GET, "/markers", TimelineMarkers.class); + addQueryParameter("timeline[]", "home"); + addQueryParameter("timeline[]", "notifications"); } } diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/requests/markers/SaveMarkers.java b/mastodon/src/main/java/org/joinmastodon/android/api/requests/markers/SaveMarkers.java index f432504bf..eeda81b44 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/api/requests/markers/SaveMarkers.java +++ b/mastodon/src/main/java/org/joinmastodon/android/api/requests/markers/SaveMarkers.java @@ -2,11 +2,11 @@ package org.joinmastodon.android.api.requests.markers; import org.joinmastodon.android.api.MastodonAPIRequest; import org.joinmastodon.android.api.gson.JsonObjectBuilder; -import org.joinmastodon.android.model.Marker; +import org.joinmastodon.android.model.TimelineMarkers; -public class SaveMarkers extends MastodonAPIRequest{ +public class SaveMarkers extends MastodonAPIRequest{ public SaveMarkers(String lastSeenHomePostID, String lastSeenNotificationID){ - super(HttpMethod.POST, "/markers", Response.class); + super(HttpMethod.POST, "/markers", TimelineMarkers.class); JsonObjectBuilder builder=new JsonObjectBuilder(); if(lastSeenHomePostID!=null) builder.add("home", new JsonObjectBuilder().add("last_read_id", lastSeenHomePostID)); @@ -14,8 +14,4 @@ public class SaveMarkers extends MastodonAPIRequest{ builder.add("notifications", new JsonObjectBuilder().add("last_read_id", lastSeenNotificationID)); setRequestBody(builder.build()); } - - public static class Response{ - public Marker home, notifications; - } } 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 b1ef8ace9..fb6cabcd9 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 @@ -4,10 +4,10 @@ import org.joinmastodon.android.api.MastodonAPIRequest; import org.joinmastodon.android.model.PushSubscription; public class RegisterForPushNotifications extends MastodonAPIRequest{ - public RegisterForPushNotifications(String deviceToken, String encryptionKey, String authKey, PushSubscription.Alerts alerts, PushSubscription.Policy policy, String accountID){ + public RegisterForPushNotifications(String endpoint, String encryptionKey, String authKey, PushSubscription.Alerts alerts, PushSubscription.Policy policy){ super(HttpMethod.POST, "/push/subscription", PushSubscription.class); Request r=new Request(); - r.subscription.endpoint="https://app.joinmastodon.org/relay-to/fcm/"+deviceToken+"/"+accountID; + r.subscription.endpoint=endpoint; r.data.alerts=alerts; r.policy=policy; r.subscription.keys.p256dh=encryptionKey; diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/requests/search/GetSearchResults.java b/mastodon/src/main/java/org/joinmastodon/android/api/requests/search/GetSearchResults.java index a35745988..0407bb702 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/api/requests/search/GetSearchResults.java +++ b/mastodon/src/main/java/org/joinmastodon/android/api/requests/search/GetSearchResults.java @@ -13,6 +13,11 @@ public class GetSearchResults extends MastodonAPIRequest{ addQueryParameter("resolve", "true"); } + public GetSearchResults limit(int limit){ + addQueryParameter("limit", String.valueOf(limit)); + return this; + } + @Override protected String getPathPrefix(){ return "/api/v2"; diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/requests/statuses/AddStatusReaction.java b/mastodon/src/main/java/org/joinmastodon/android/api/requests/statuses/AddStatusReaction.java new file mode 100644 index 000000000..536634655 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/api/requests/statuses/AddStatusReaction.java @@ -0,0 +1,11 @@ +package org.joinmastodon.android.api.requests.statuses; + +import org.joinmastodon.android.api.MastodonAPIRequest; +import org.joinmastodon.android.model.Status; + +public class AddStatusReaction extends MastodonAPIRequest { + public AddStatusReaction(String id, String emoji) { + super(HttpMethod.POST, "/statuses/" + id + "/react/" + emoji, Status.class); + setRequestBody(new Object()); + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/requests/statuses/DeleteStatusReaction.java b/mastodon/src/main/java/org/joinmastodon/android/api/requests/statuses/DeleteStatusReaction.java new file mode 100644 index 000000000..133b09730 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/api/requests/statuses/DeleteStatusReaction.java @@ -0,0 +1,11 @@ +package org.joinmastodon.android.api.requests.statuses; + +import org.joinmastodon.android.api.MastodonAPIRequest; +import org.joinmastodon.android.model.Status; + +public class DeleteStatusReaction extends MastodonAPIRequest { + public DeleteStatusReaction(String id, String emoji) { + super(HttpMethod.POST, "/statuses/" + id + "/unreact/" + emoji, Status.class); + setRequestBody(new Object()); + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/requests/statuses/PleromaAddStatusReaction.java b/mastodon/src/main/java/org/joinmastodon/android/api/requests/statuses/PleromaAddStatusReaction.java new file mode 100644 index 000000000..fcf25cfb7 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/api/requests/statuses/PleromaAddStatusReaction.java @@ -0,0 +1,11 @@ +package org.joinmastodon.android.api.requests.statuses; + +import org.joinmastodon.android.api.MastodonAPIRequest; +import org.joinmastodon.android.model.Status; + +public class PleromaAddStatusReaction extends MastodonAPIRequest { + public PleromaAddStatusReaction(String id, String emoji) { + super(HttpMethod.PUT, "/pleroma/statuses/" + id + "/reactions/" + emoji, Status.class); + setRequestBody(new Object()); + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/requests/statuses/PleromaDeleteStatusReaction.java b/mastodon/src/main/java/org/joinmastodon/android/api/requests/statuses/PleromaDeleteStatusReaction.java new file mode 100644 index 000000000..5657c23df --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/api/requests/statuses/PleromaDeleteStatusReaction.java @@ -0,0 +1,10 @@ +package org.joinmastodon.android.api.requests.statuses; + +import org.joinmastodon.android.api.MastodonAPIRequest; +import org.joinmastodon.android.model.Status; + +public class PleromaDeleteStatusReaction extends MastodonAPIRequest { + public PleromaDeleteStatusReaction(String id, String emoji) { + super(HttpMethod.DELETE, "/pleroma/statuses/" + id + "/reactions/" + emoji, Status.class); + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/requests/statuses/PleromaGetStatusReactions.java b/mastodon/src/main/java/org/joinmastodon/android/api/requests/statuses/PleromaGetStatusReactions.java new file mode 100644 index 000000000..e344321db --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/api/requests/statuses/PleromaGetStatusReactions.java @@ -0,0 +1,14 @@ +package org.joinmastodon.android.api.requests.statuses; + +import com.google.gson.reflect.TypeToken; + +import org.joinmastodon.android.api.MastodonAPIRequest; +import org.joinmastodon.android.model.EmojiReaction; + +import java.util.List; + +public class PleromaGetStatusReactions extends MastodonAPIRequest> { + public PleromaGetStatusReactions(String id, String emoji) { + super(HttpMethod.GET, "/pleroma/statuses/" + id + "/reactions/" + (emoji != null ? emoji : ""), new TypeToken<>(){}); + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/requests/timelines/GetBubbleTimeline.java b/mastodon/src/main/java/org/joinmastodon/android/api/requests/timelines/GetBubbleTimeline.java index 9b54d1895..fbb19a0f0 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/api/requests/timelines/GetBubbleTimeline.java +++ b/mastodon/src/main/java/org/joinmastodon/android/api/requests/timelines/GetBubbleTimeline.java @@ -4,20 +4,19 @@ import android.text.TextUtils; import com.google.gson.reflect.TypeToken; -import org.joinmastodon.android.GlobalUserPreferences; import org.joinmastodon.android.api.MastodonAPIRequest; import org.joinmastodon.android.model.Status; import java.util.List; public class GetBubbleTimeline extends MastodonAPIRequest> { - public GetBubbleTimeline(String maxID, int limit) { + public GetBubbleTimeline(String maxID, int limit, String replyVisibility) { super(HttpMethod.GET, "/timelines/bubble", new TypeToken<>(){}); if(!TextUtils.isEmpty(maxID)) addQueryParameter("max_id", maxID); if(limit>0) addQueryParameter("limit", limit+""); - if(GlobalUserPreferences.replyVisibility != null) - addQueryParameter("reply_visibility", GlobalUserPreferences.replyVisibility); + if(replyVisibility != null) + addQueryParameter("reply_visibility", replyVisibility); } } diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/requests/timelines/GetHashtagTimeline.java b/mastodon/src/main/java/org/joinmastodon/android/api/requests/timelines/GetHashtagTimeline.java index 6cf31542d..a47c47d61 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/api/requests/timelines/GetHashtagTimeline.java +++ b/mastodon/src/main/java/org/joinmastodon/android/api/requests/timelines/GetHashtagTimeline.java @@ -2,16 +2,13 @@ package org.joinmastodon.android.api.requests.timelines; import com.google.gson.reflect.TypeToken; -import android.text.TextUtils; - -import org.joinmastodon.android.GlobalUserPreferences; import org.joinmastodon.android.api.MastodonAPIRequest; import org.joinmastodon.android.model.Status; import java.util.List; public class GetHashtagTimeline extends MastodonAPIRequest>{ - public GetHashtagTimeline(String hashtag, String maxID, String minID, int limit, List containsAny, List containsAll, List containsNone, boolean localOnly){ + public GetHashtagTimeline(String hashtag, String maxID, String minID, int limit, List containsAny, List containsAll, List containsNone, boolean localOnly, String replyVisibility){ super(HttpMethod.GET, "/timelines/tag/"+hashtag, new TypeToken<>(){}); if (localOnly) addQueryParameter("local", "true"); @@ -30,17 +27,7 @@ public class GetHashtagTimeline extends MastodonAPIRequest>{ if(containsNone!=null) for (String tag : containsNone) addQueryParameter("none[]", tag); - } - - public GetHashtagTimeline(String hashtag, String maxID, String minID, int limit){ - super(HttpMethod.GET, "/timelines/tag/"+hashtag, new TypeToken<>(){}); - if(maxID!=null) - addQueryParameter("max_id", maxID); - if(minID!=null) - addQueryParameter("min_id", minID); - if(limit>0) - addQueryParameter("limit", ""+limit); - if(GlobalUserPreferences.replyVisibility != null) - addQueryParameter("reply_visibility", GlobalUserPreferences.replyVisibility); + if(replyVisibility != null) + addQueryParameter("reply_visibility", replyVisibility); } } diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/requests/timelines/GetHomeTimeline.java b/mastodon/src/main/java/org/joinmastodon/android/api/requests/timelines/GetHomeTimeline.java index 3792c5a66..1a605cab2 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/api/requests/timelines/GetHomeTimeline.java +++ b/mastodon/src/main/java/org/joinmastodon/android/api/requests/timelines/GetHomeTimeline.java @@ -2,14 +2,13 @@ package org.joinmastodon.android.api.requests.timelines; import com.google.gson.reflect.TypeToken; -import org.joinmastodon.android.GlobalUserPreferences; import org.joinmastodon.android.api.MastodonAPIRequest; import org.joinmastodon.android.model.Status; import java.util.List; public class GetHomeTimeline extends MastodonAPIRequest>{ - public GetHomeTimeline(String maxID, String minID, int limit, String sinceID){ + public GetHomeTimeline(String maxID, String minID, int limit, String sinceID, String replyVisibility){ super(HttpMethod.GET, "/timelines/home", new TypeToken<>(){}); if(maxID!=null) addQueryParameter("max_id", maxID); @@ -19,7 +18,7 @@ public class GetHomeTimeline extends MastodonAPIRequest>{ addQueryParameter("since_id", sinceID); if(limit>0) addQueryParameter("limit", ""+limit); - if(GlobalUserPreferences.replyVisibility != null) - addQueryParameter("reply_visibility", GlobalUserPreferences.replyVisibility); + if(replyVisibility != null) + addQueryParameter("reply_visibility", replyVisibility); } } diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/requests/timelines/GetListTimeline.java b/mastodon/src/main/java/org/joinmastodon/android/api/requests/timelines/GetListTimeline.java index 82d537971..29dfb67ac 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/api/requests/timelines/GetListTimeline.java +++ b/mastodon/src/main/java/org/joinmastodon/android/api/requests/timelines/GetListTimeline.java @@ -2,14 +2,13 @@ package org.joinmastodon.android.api.requests.timelines; import com.google.gson.reflect.TypeToken; -import org.joinmastodon.android.GlobalUserPreferences; import org.joinmastodon.android.api.MastodonAPIRequest; import org.joinmastodon.android.model.Status; import java.util.List; public class GetListTimeline extends MastodonAPIRequest> { - public GetListTimeline(String listID, String maxID, String minID, int limit, String sinceID) { + public GetListTimeline(String listID, String maxID, String minID, int limit, String sinceID, String replyVisibility) { super(HttpMethod.GET, "/timelines/list/"+listID, new TypeToken<>(){}); if(maxID!=null) addQueryParameter("max_id", maxID); @@ -19,7 +18,7 @@ public class GetListTimeline extends MastodonAPIRequest> { addQueryParameter("limit", ""+limit); if(sinceID!=null) addQueryParameter("since_id", sinceID); - if(GlobalUserPreferences.replyVisibility != null) - addQueryParameter("reply_visibility", GlobalUserPreferences.replyVisibility); + if(replyVisibility != null) + addQueryParameter("reply_visibility", replyVisibility); } } diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/requests/timelines/GetPublicTimeline.java b/mastodon/src/main/java/org/joinmastodon/android/api/requests/timelines/GetPublicTimeline.java index 7ec562704..328bf869a 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/api/requests/timelines/GetPublicTimeline.java +++ b/mastodon/src/main/java/org/joinmastodon/android/api/requests/timelines/GetPublicTimeline.java @@ -4,14 +4,13 @@ import android.text.TextUtils; import com.google.gson.reflect.TypeToken; -import org.joinmastodon.android.GlobalUserPreferences; import org.joinmastodon.android.api.MastodonAPIRequest; import org.joinmastodon.android.model.Status; import java.util.List; public class GetPublicTimeline extends MastodonAPIRequest>{ - public GetPublicTimeline(boolean local, boolean remote, String maxID, int limit){ + public GetPublicTimeline(boolean local, boolean remote, String maxID, int limit, String replyVisibility){ super(HttpMethod.GET, "/timelines/public", new TypeToken<>(){}); if(local) addQueryParameter("local", "true"); @@ -21,7 +20,7 @@ public class GetPublicTimeline extends MastodonAPIRequest>{ addQueryParameter("max_id", maxID); if(limit>0) addQueryParameter("limit", limit+""); - if(GlobalUserPreferences.replyVisibility != null) - addQueryParameter("reply_visibility", GlobalUserPreferences.replyVisibility); + if(replyVisibility != null) + addQueryParameter("reply_visibility", replyVisibility); } } diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/session/AccountLocalPreferences.java b/mastodon/src/main/java/org/joinmastodon/android/api/session/AccountLocalPreferences.java new file mode 100644 index 000000000..4589ec4ed --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/api/session/AccountLocalPreferences.java @@ -0,0 +1,105 @@ +package org.joinmastodon.android.api.session; + +import static org.joinmastodon.android.GlobalUserPreferences.fromJson; +import static org.joinmastodon.android.GlobalUserPreferences.enumValue; +import static org.joinmastodon.android.api.MastodonAPIController.gson; + +import android.content.SharedPreferences; + +import com.google.gson.reflect.TypeToken; + +import org.joinmastodon.android.model.ContentType; +import org.joinmastodon.android.model.TimelineDefinition; + +import java.lang.reflect.Type; +import java.util.ArrayList; +import java.util.List; + +public class AccountLocalPreferences{ + private final SharedPreferences prefs; + + public boolean showInteractionCounts; + public boolean customEmojiInNames; + public boolean revealCWs; + public boolean hideSensitiveMedia; + public boolean serverSideFiltersSupported; + + // MEGALODON + public boolean showReplies; + public boolean showBoosts; + public ArrayList recentLanguages; + public boolean bottomEncoding; + public ContentType defaultContentType; + public boolean contentTypesEnabled; + public ArrayList timelines; + public boolean localOnlySupported; + public boolean glitchInstance; + public String publishButtonText; + public String timelineReplyVisibility; // akkoma-only + public boolean keepOnlyLatestNotification; + + public boolean emojiReactionsEnabled; + public boolean showEmojiReactionsInLists; + + private final static Type recentLanguagesType = new TypeToken>() {}.getType(); + private final static Type timelinesType = new TypeToken>() {}.getType(); + + public AccountLocalPreferences(SharedPreferences prefs, AccountSession session){ + this.prefs=prefs; + showInteractionCounts=prefs.getBoolean("interactionCounts", false); + customEmojiInNames=prefs.getBoolean("emojiInNames", true); + revealCWs=prefs.getBoolean("revealCWs", false); + hideSensitiveMedia=prefs.getBoolean("hideSensitive", true); + serverSideFiltersSupported=prefs.getBoolean("serverSideFilters", false); + + // MEGALODON + showReplies=prefs.getBoolean("showReplies", true); + showBoosts=prefs.getBoolean("showBoosts", true); + recentLanguages=fromJson(prefs.getString("recentLanguages", null), recentLanguagesType, new ArrayList<>()); + bottomEncoding=prefs.getBoolean("bottomEncoding", false); + defaultContentType=enumValue(ContentType.class, prefs.getString("defaultContentType", ContentType.PLAIN.name())); + contentTypesEnabled=prefs.getBoolean("contentTypesEnabled", true); + timelines=fromJson(prefs.getString("timelines", null), timelinesType, TimelineDefinition.getDefaultTimelines(session.getID())); + localOnlySupported=prefs.getBoolean("localOnlySupported", false); + glitchInstance=prefs.getBoolean("glitchInstance", false); + publishButtonText=prefs.getString("publishButtonText", null); + timelineReplyVisibility=prefs.getString("timelineReplyVisibility", null); + keepOnlyLatestNotification=prefs.getBoolean("keepOnlyLatestNotification", false); + emojiReactionsEnabled=prefs.getBoolean("emojiReactionsEnabled", session.getInstance().isPresent() && session.getInstance().get().isAkkoma()); + showEmojiReactionsInLists=prefs.getBoolean("showEmojiReactionsInLists", false); + } + + public long getNotificationsPauseEndTime(){ + return prefs.getLong("notificationsPauseTime", 0L); + } + + public void setNotificationsPauseEndTime(long time){ + prefs.edit().putLong("notificationsPauseTime", time).apply(); + } + + public void save(){ + prefs.edit() + .putBoolean("interactionCounts", showInteractionCounts) + .putBoolean("emojiInNames", customEmojiInNames) + .putBoolean("revealCWs", revealCWs) + .putBoolean("hideSensitive", hideSensitiveMedia) + .putBoolean("serverSideFilters", serverSideFiltersSupported) + + // MEGALODON + .putBoolean("showReplies", showReplies) + .putBoolean("showBoosts", showBoosts) + .putString("recentLanguages", gson.toJson(recentLanguages)) + .putBoolean("bottomEncoding", bottomEncoding) + .putString("defaultContentType", defaultContentType==null ? null : defaultContentType.name()) + .putBoolean("contentTypesEnabled", contentTypesEnabled) + .putString("timelines", gson.toJson(timelines)) + .putBoolean("localOnlySupported", localOnlySupported) + .putBoolean("glitchInstance", glitchInstance) + .putString("publishButtonText", publishButtonText) + .putString("timelineReplyVisibility", timelineReplyVisibility) + .putBoolean("keepOnlyLatestNotification", keepOnlyLatestNotification) + .putBoolean("emojiReactionsEnabled", emojiReactionsEnabled) + .putBoolean("showEmojiReactionsInLists", showEmojiReactionsInLists) + .apply(); + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/session/AccountSession.java b/mastodon/src/main/java/org/joinmastodon/android/api/session/AccountSession.java index 30acb30d6..fde8cc566 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/api/session/AccountSession.java +++ b/mastodon/src/main/java/org/joinmastodon/android/api/session/AccountSession.java @@ -1,25 +1,53 @@ package org.joinmastodon.android.api.session; +import android.app.Activity; +import android.content.Context; +import android.content.SharedPreferences; import android.net.Uri; +import android.text.TextUtils; +import android.util.Log; +import org.joinmastodon.android.E; +import org.joinmastodon.android.MastodonApp; +import org.joinmastodon.android.R; import org.joinmastodon.android.api.CacheController; import org.joinmastodon.android.api.MastodonAPIController; import org.joinmastodon.android.api.PushSubscriptionManager; import org.joinmastodon.android.api.StatusInteractionController; +import org.joinmastodon.android.api.requests.accounts.GetPreferences; +import org.joinmastodon.android.api.requests.accounts.UpdateAccountCredentialsPreferences; +import org.joinmastodon.android.api.requests.markers.GetMarkers; +import org.joinmastodon.android.api.requests.markers.SaveMarkers; +import org.joinmastodon.android.api.requests.oauth.RevokeOauthToken; +import org.joinmastodon.android.events.NotificationsMarkerUpdatedEvent; import org.joinmastodon.android.model.Account; import org.joinmastodon.android.model.Application; -import org.joinmastodon.android.model.Filter; +import org.joinmastodon.android.model.FilterAction; +import org.joinmastodon.android.model.FilterContext; +import org.joinmastodon.android.model.FilterResult; import org.joinmastodon.android.model.Instance; -import org.joinmastodon.android.model.Markers; +import org.joinmastodon.android.model.LegacyFilter; import org.joinmastodon.android.model.Preferences; import org.joinmastodon.android.model.PushSubscription; +import org.joinmastodon.android.model.Status; +import org.joinmastodon.android.model.TimelineMarkers; import org.joinmastodon.android.model.Token; +import org.joinmastodon.android.utils.ObjectIdComparator; import java.util.ArrayList; import java.util.List; +import java.util.Objects; import java.util.Optional; +import java.util.function.Consumer; +import java.util.function.Function; +import java.util.function.Predicate; + +import me.grishka.appkit.api.Callback; +import me.grishka.appkit.api.ErrorResponse; public class AccountSession{ + private static final String TAG="AccountSession"; + public Token token; public Account self; public String domain; @@ -32,15 +60,17 @@ public class AccountSession{ public PushSubscription pushSubscription; public boolean needUpdatePushSettings; public long filtersLastUpdated; - public List wordFilters=new ArrayList<>(); + public List wordFilters=new ArrayList<>(); public String pushAccountID; - public Preferences preferences; public AccountActivationInfo activationInfo; - public Markers markers; + public Preferences preferences; private transient MastodonAPIController apiController; private transient StatusInteractionController statusInteractionController, remoteStatusInteractionController; private transient CacheController cacheController; private transient PushSubscriptionManager pushSubscriptionManager; + private transient SharedPreferences prefs; + private transient boolean preferencesNeedSaving; + private transient AccountLocalPreferences localPreferences; AccountSession(Token token, Account self, Application app, String domain, boolean activated, AccountActivationInfo activationInfo){ this.token=token; @@ -58,10 +88,6 @@ public class AccountSession{ return domain+"_"+self.id; } - public String getFullUsername() { - return "@"+self.username+"@"+domain; - } - public MastodonAPIController getApiController(){ if(apiController==null) apiController=new MastodonAPIController(this); @@ -92,6 +118,188 @@ public class AccountSession{ return pushSubscriptionManager; } + public String getFullUsername(){ + return '@'+self.username+'@'+domain; + } + + public void preferencesFromAccountSource(Account account) { + if (account != null && account.source != null && preferences != null) { + if (account.source.privacy != null) + preferences.postingDefaultVisibility = account.source.privacy; + if (account.source.language != null) + preferences.postingDefaultLanguage = account.source.language; + } + } + + public void reloadPreferences(Consumer callback){ + new GetPreferences() + .setCallback(new Callback<>(){ + @Override + public void onSuccess(Preferences result){ + preferences=result; + preferencesFromAccountSource(self); + if(callback!=null) + callback.accept(result); + AccountSessionManager.getInstance().writeAccountsFile(); + } + + @Override + public void onError(ErrorResponse error){ + Log.w(TAG, "Failed to load preferences for account "+getID()+": "+error); + } + }) + .exec(getID()); + } + + public SharedPreferences getRawLocalPreferences(){ + if(prefs==null) + prefs=MastodonApp.context.getSharedPreferences(getID(), Context.MODE_PRIVATE); + return prefs; + } + + public void reloadNotificationsMarker(Consumer callback){ + new GetMarkers() + .setCallback(new Callback<>(){ + @Override + public void onSuccess(TimelineMarkers result){ + if(result.notifications!=null && !TextUtils.isEmpty(result.notifications.lastReadId)){ + String id=result.notifications.lastReadId; + String lastKnown=getLastKnownNotificationsMarker(); + if(ObjectIdComparator.INSTANCE.compare(id, lastKnown)<0){ + // Marker moved back -- previous marker update must have failed. + // Pretend it didn't happen and repeat the request. + id=lastKnown; + new SaveMarkers(null, id).exec(getID()); + } + callback.accept(id); + setNotificationsMarker(id, false); + } + } + + @Override + public void onError(ErrorResponse error){} + }) + .exec(getID()); + } + + public String getLastKnownNotificationsMarker(){ + return getRawLocalPreferences().getString("notificationsMarker", null); + } + + public void setNotificationsMarker(String id, boolean clearUnread){ + getRawLocalPreferences().edit().putString("notificationsMarker", id).apply(); + E.post(new NotificationsMarkerUpdatedEvent(getID(), id, clearUnread)); + } + + public void logOut(Activity activity, Runnable onDone){ + new RevokeOauthToken(app.clientId, app.clientSecret, token.accessToken) + .setCallback(new Callback<>(){ + @Override + public void onSuccess(Object result){ + AccountSessionManager.getInstance().removeAccount(getID()); + onDone.run(); + } + + @Override + public void onError(ErrorResponse error){ + AccountSessionManager.getInstance().removeAccount(getID()); + onDone.run(); + } + }) + .wrapProgress(activity, R.string.loading, false) + .exec(getID()); + } + + public void savePreferencesLater(){ + preferencesNeedSaving=true; + } + + public void savePreferencesIfPending(){ + if(preferencesNeedSaving){ + new UpdateAccountCredentialsPreferences(preferences, null, null) + .setCallback(new Callback<>(){ + @Override + public void onSuccess(Account result){ + preferencesNeedSaving=false; + self=result; + AccountSessionManager.getInstance().writeAccountsFile(); + } + + @Override + public void onError(ErrorResponse error){ + Log.e(TAG, "failed to save preferences: "+error); + } + }) + .exec(getID()); + } + } + + public AccountLocalPreferences getLocalPreferences(){ + if(localPreferences==null) + localPreferences=new AccountLocalPreferences(getRawLocalPreferences(), this); + return localPreferences; + } + + public void filterStatuses(List statuses, FilterContext context){ + filterStatuses(statuses, context, null); + } + + public void filterStatuses(List statuses, FilterContext context, Account profile){ + filterStatusContainingObjects(statuses, Function.identity(), context, profile); + } + + public void filterStatusContainingObjects(List objects, Function extractor, FilterContext context){ + filterStatusContainingObjects(objects, extractor, context, null); + } + + public void filterStatusContainingObjects(List objects, Function extractor, FilterContext context, Account profile){ + Predicate statusIsOnOwnProfile = (s) -> self != null && profile != null && s.account != null + && Objects.equals(self.id, profile.id) && Objects.equals(self.id, s.account.id); + + if(getLocalPreferences().serverSideFiltersSupported){ + // Even with server-side filters, clients are expected to remove statuses that match a filter that hides them + objects.removeIf(o->{ + Status s=extractor.apply(o); + if(s==null) + return false; + if(s.filtered==null) + return false; + // don't hide own posts in own profile + if (statusIsOnOwnProfile.test(s)) + return false; + for(FilterResult filter:s.filtered){ + if(filter.filter.isActive() && filter.filter.filterAction==FilterAction.HIDE) + return true; + } + return false; + }); + return; + } + if(wordFilters==null) + return; + for(T obj:objects){ + Status s=extractor.apply(obj); + if(s!=null && s.filtered!=null){ + getLocalPreferences().serverSideFiltersSupported=true; + getLocalPreferences().save(); + return; + } + } + objects.removeIf(o->{ + Status s=extractor.apply(o); + if(s==null) + return false; + // don't hide own posts in own profile + if (statusIsOnOwnProfile.test(s)) + return false; + for(LegacyFilter filter:wordFilters){ + if(filter.context.contains(context) && filter.matches(s) && filter.isActive()) + return true; + } + return false; + }); + } + public Optional getInstance() { return Optional.ofNullable(AccountSessionManager.getInstance().getInstanceInfo(domain)); } diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/session/AccountSessionManager.java b/mastodon/src/main/java/org/joinmastodon/android/api/session/AccountSessionManager.java index b424925bc..d7ce4df4d 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/api/session/AccountSessionManager.java +++ b/mastodon/src/main/java/org/joinmastodon/android/api/session/AccountSessionManager.java @@ -21,23 +21,18 @@ import org.joinmastodon.android.MastodonApp; import org.joinmastodon.android.R; import org.joinmastodon.android.api.MastodonAPIController; import org.joinmastodon.android.api.PushSubscriptionManager; -import org.joinmastodon.android.api.requests.accounts.GetPreferences; -import org.joinmastodon.android.api.requests.accounts.GetWordFilters; +import org.joinmastodon.android.api.requests.filters.GetLegacyFilters; import org.joinmastodon.android.api.requests.instance.GetCustomEmojis; import org.joinmastodon.android.api.requests.accounts.GetOwnAccount; import org.joinmastodon.android.api.requests.instance.GetInstance; -import org.joinmastodon.android.api.requests.markers.GetMarkers; import org.joinmastodon.android.api.requests.oauth.CreateOAuthApp; import org.joinmastodon.android.events.EmojiUpdatedEvent; import org.joinmastodon.android.model.Account; import org.joinmastodon.android.model.Application; import org.joinmastodon.android.model.Emoji; import org.joinmastodon.android.model.EmojiCategory; -import org.joinmastodon.android.model.Filter; +import org.joinmastodon.android.model.LegacyFilter; import org.joinmastodon.android.model.Instance; -import org.joinmastodon.android.model.Marker; -import org.joinmastodon.android.model.Markers; -import org.joinmastodon.android.model.Preferences; import org.joinmastodon.android.model.Token; import java.io.File; @@ -50,10 +45,10 @@ import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.Collections; import java.util.Comparator; -import java.util.EnumSet; import java.util.HashMap; import java.util.HashSet; import java.util.List; +import java.util.Optional; import java.util.Set; import java.util.stream.Collectors; @@ -166,11 +161,19 @@ public class AccountSessionManager{ return session; } + public static AccountSession get(String id){ + return getInstance().getAccount(id); + } + @Nullable public AccountSession tryGetAccount(String id){ return sessions.get(id); } + public static Optional getOptional(String id) { + return Optional.ofNullable(getInstance().tryGetAccount(id)); + } + @Nullable public AccountSession tryGetAccount(Account account) { return sessions.get(account.getDomainFromURL() + "_" + account.id); @@ -203,13 +206,19 @@ public class AccountSessionManager{ AccountSession session=getAccount(id); session.getCacheController().closeDatabase(); MastodonApp.context.deleteDatabase(id+".db"); - GlobalUserPreferences.removeAccount(id); + MastodonApp.context.getSharedPreferences(id, 0).edit().clear().commit(); + if(Build.VERSION.SDK_INT>=Build.VERSION_CODES.N){ + MastodonApp.context.deleteSharedPreferences(id); + }else{ + new File(MastodonApp.context.getDir("shared_prefs", Context.MODE_PRIVATE), id+".xml").delete(); + } sessions.remove(id); if(lastActiveAccountID.equals(id)){ if(sessions.isEmpty()) lastActiveAccountID=null; else lastActiveAccountID=getLoggedInAccounts().get(0).getID(); + prefs.edit().putString("lastActiveAccount", lastActiveAccountID).apply(); } writeAccountsFile(); String domain=session.domain.toLowerCase(); @@ -282,14 +291,13 @@ public class AccountSessionManager{ HashSet domains=new HashSet<>(); for(AccountSession session:sessions.values()){ domains.add(session.domain.toLowerCase()); - if(now-session.infoLastUpdated>24L*3600_000L || session == activeSession){ - updateSessionPreferences(session); + if(session == activeSession || now-session.infoLastUpdated>24L*3600_000L){ + session.reloadPreferences(null); updateSessionLocalInfo(session); } - if(now-session.filtersLastUpdated>3600_000L || session == activeSession){ + if(session == activeSession || (session.getLocalPreferences().serverSideFiltersSupported && now-session.filtersLastUpdated>3600_000L)){ updateSessionWordFilters(session); } - updateSessionMarkers(session); } if(loadedInstances){ maybeUpdateCustomEmojis(domains, activeSession != null ? activeSession.domain : null); @@ -300,20 +308,12 @@ public class AccountSessionManager{ long now=System.currentTimeMillis(); for(String domain:domains){ Long lastUpdated=instancesLastUpdated.get(domain); - if(lastUpdated==null || now-lastUpdated>24L*3600_000L || domain.equals(activeDomain)){ + if(domain.equals(activeDomain) || lastUpdated==null || now-lastUpdated>24L*3600_000L){ updateInstanceInfo(domain); } } } - private void preferencesFromSource(AccountSession session, Account account) { - if (account != null && account.source != null && session.preferences != null) { - if (account.source.privacy != null) - session.preferences.postingDefaultVisibility = account.source.privacy; - if (account.source.language != null) - session.preferences.postingDefaultLanguage = account.source.language; - } - } private void updateSessionLocalInfo(AccountSession session){ new GetOwnAccount() @@ -322,39 +322,7 @@ public class AccountSessionManager{ public void onSuccess(Account result){ session.self=result; session.infoLastUpdated=System.currentTimeMillis(); - preferencesFromSource(session, result); - writeAccountsFile(); - } - - @Override - public void onError(ErrorResponse error){} - }) - .exec(session.getID()); - } - - private void updateSessionPreferences(AccountSession session){ - new GetPreferences().setCallback(new Callback<>() { - @Override - public void onSuccess(Preferences preferences) { - session.preferences=preferences; - preferencesFromSource(session, session.self); - } - - @Override - public void onError(ErrorResponse error) { - session.preferences = new Preferences(); - preferencesFromSource(session, session.self); - } - }).exec(session.getID()); - } - - private void updateSessionWordFilters(AccountSession session){ - new GetWordFilters() - .setCallback(new Callback<>(){ - @Override - public void onSuccess(List result){ - session.wordFilters=result; - session.filtersLastUpdated=System.currentTimeMillis(); + session.preferencesFromAccountSource(result); writeAccountsFile(); } @@ -366,19 +334,22 @@ public class AccountSessionManager{ .exec(session.getID()); } - private void updateSessionMarkers(AccountSession session) { - new GetMarkers(EnumSet.allOf(Marker.Type.class)).setCallback(new Callback<>() { - @Override - public void onSuccess(Markers markers) { - session.markers = markers; - writeAccountsFile(); - } + private void updateSessionWordFilters(AccountSession session){ + new GetLegacyFilters() + .setCallback(new Callback<>(){ + @Override + public void onSuccess(List result){ + session.wordFilters=result; + session.filtersLastUpdated=System.currentTimeMillis(); + writeAccountsFile(); + } - @Override - public void onError(ErrorResponse error) { + @Override + public void onError(ErrorResponse error){ - } - }).exec(session.getID()); + } + }) + .exec(session.getID()); } public void updateInstanceInfo(String domain){ diff --git a/mastodon/src/main/java/org/joinmastodon/android/events/AllNotificationsSeenEvent.java b/mastodon/src/main/java/org/joinmastodon/android/events/AllNotificationsSeenEvent.java deleted file mode 100644 index aded8546a..000000000 --- a/mastodon/src/main/java/org/joinmastodon/android/events/AllNotificationsSeenEvent.java +++ /dev/null @@ -1,4 +0,0 @@ -package org.joinmastodon.android.events; - -public class AllNotificationsSeenEvent { -} diff --git a/mastodon/src/main/java/org/joinmastodon/android/events/NotificationReceivedEvent.java b/mastodon/src/main/java/org/joinmastodon/android/events/NotificationReceivedEvent.java deleted file mode 100644 index 7641a4bdb..000000000 --- a/mastodon/src/main/java/org/joinmastodon/android/events/NotificationReceivedEvent.java +++ /dev/null @@ -1,9 +0,0 @@ -package org.joinmastodon.android.events; - -public class NotificationReceivedEvent { - public String account, id; - public NotificationReceivedEvent(String account, String id) { - this.account = account; - this.id = id; - } -} diff --git a/mastodon/src/main/java/org/joinmastodon/android/events/NotificationsMarkerUpdatedEvent.java b/mastodon/src/main/java/org/joinmastodon/android/events/NotificationsMarkerUpdatedEvent.java new file mode 100644 index 000000000..f68a5b99d --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/events/NotificationsMarkerUpdatedEvent.java @@ -0,0 +1,13 @@ +package org.joinmastodon.android.events; + +public class NotificationsMarkerUpdatedEvent{ + public final String accountID; + public final String marker; + public final boolean clearUnread; + + public NotificationsMarkerUpdatedEvent(String accountID, String marker, boolean clearUnread){ + this.accountID=accountID; + this.marker=marker; + this.clearUnread=clearUnread; + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/events/SettingsFilterCreatedOrUpdatedEvent.java b/mastodon/src/main/java/org/joinmastodon/android/events/SettingsFilterCreatedOrUpdatedEvent.java new file mode 100644 index 000000000..2b85d9640 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/events/SettingsFilterCreatedOrUpdatedEvent.java @@ -0,0 +1,13 @@ +package org.joinmastodon.android.events; + +import org.joinmastodon.android.model.Filter; + +public class SettingsFilterCreatedOrUpdatedEvent{ + public final String accountID; + public final Filter filter; + + public SettingsFilterCreatedOrUpdatedEvent(String accountID, Filter filter){ + this.accountID=accountID; + this.filter=filter; + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/events/SettingsFilterDeletedEvent.java b/mastodon/src/main/java/org/joinmastodon/android/events/SettingsFilterDeletedEvent.java new file mode 100644 index 000000000..d89069ed8 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/events/SettingsFilterDeletedEvent.java @@ -0,0 +1,11 @@ +package org.joinmastodon.android.events; + +public class SettingsFilterDeletedEvent{ + public final String accountID; + public final String filterID; + + public SettingsFilterDeletedEvent(String accountID, String filterID){ + this.accountID=accountID; + this.filterID=filterID; + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/events/StatusCountersUpdatedEvent.java b/mastodon/src/main/java/org/joinmastodon/android/events/StatusCountersUpdatedEvent.java index d1f990585..3027a3809 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/events/StatusCountersUpdatedEvent.java +++ b/mastodon/src/main/java/org/joinmastodon/android/events/StatusCountersUpdatedEvent.java @@ -1,14 +1,29 @@ package org.joinmastodon.android.events; +import androidx.recyclerview.widget.RecyclerView; + +import org.joinmastodon.android.api.CacheController; +import org.joinmastodon.android.model.EmojiReaction; import org.joinmastodon.android.model.Status; +import java.util.ArrayList; +import java.util.List; + public class StatusCountersUpdatedEvent{ public String id; public long favorites, reblogs, replies; public boolean favorited, reblogged, bookmarked, pinned; + public List reactions; + public Status status; + public RecyclerView.ViewHolder viewHolder; public StatusCountersUpdatedEvent(Status s){ + this(s, null); + } + + public StatusCountersUpdatedEvent(Status s, RecyclerView.ViewHolder vh){ id=s.id; + status=s; favorites=s.favouritesCount; reblogs=s.reblogsCount; replies=s.repliesCount; @@ -16,5 +31,7 @@ public class StatusCountersUpdatedEvent{ reblogged=s.reblogged; bookmarked=s.bookmarked; pinned=s.pinned; + reactions=new ArrayList<>(s.reactions); + viewHolder=vh; } } diff --git a/mastodon/src/main/java/org/joinmastodon/android/events/StatusDisplaySettingsChangedEvent.java b/mastodon/src/main/java/org/joinmastodon/android/events/StatusDisplaySettingsChangedEvent.java new file mode 100644 index 000000000..b4f63a098 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/events/StatusDisplaySettingsChangedEvent.java @@ -0,0 +1,9 @@ +package org.joinmastodon.android.events; + +public class StatusDisplaySettingsChangedEvent{ + public final String accountID; + + public StatusDisplaySettingsChangedEvent(String accountID){ + this.accountID=accountID; + } +} 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 16e6d4ac8..3451eb9a4 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/AccountTimelineFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/AccountTimelineFragment.java @@ -12,7 +12,7 @@ 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.FilterContext; import org.joinmastodon.android.model.Status; import org.joinmastodon.android.ui.displayitems.HeaderStatusDisplayItem; import org.joinmastodon.android.utils.StatusFilterPredicate; @@ -48,27 +48,22 @@ public class AccountTimelineFragment extends StatusListFragment{ @Override public void onAttach(Activity activity){ - super.onAttach(activity); user=Parcels.unwrap(getArguments().getParcelable("profileAccount")); filter=GetAccountStatuses.Filter.valueOf(getArguments().getString("filter")); + super.onAttach(activity); } @Override protected void doLoadData(int offset, int count){ - if(user==null) // TODO figure out why this happens - return; currentRequest=new GetAccountStatuses(user.id, offset>0 ? getMaxID() : null, null, count, filter) .setCallback(new SimpleCallback<>(this){ @Override public void onSuccess(List result){ if(getActivity()==null) return; AccountSessionManager asm = AccountSessionManager.getInstance(); - result=result.stream().filter(status -> { - // don't hide own posts in own profile - if (asm.isSelf(accountID, user) && asm.isSelf(accountID, status.account)) return true; - else return new StatusFilterPredicate(accountID, getFilterContext()).test(status); - }).collect(Collectors.toList()); - onDataLoaded(result, !result.isEmpty()); + boolean empty=result.isEmpty(); + AccountSessionManager.get(accountID).filterStatuses(result, getFilterContext(), user); + onDataLoaded(result, !empty); } }) .exec(accountID); @@ -77,7 +72,7 @@ public class AccountTimelineFragment extends StatusListFragment{ @Override public void onViewCreated(View view, Bundle savedInstanceState){ super.onViewCreated(view, savedInstanceState); - fab = ((ProfileFragment) getParentFragment()).getFab(); + view.setBackground(null); // prevents unnecessary overdraw } @Override @@ -87,20 +82,20 @@ public class AccountTimelineFragment extends StatusListFragment{ loadData(); } - protected void onStatusCreated(StatusCreatedEvent ev){ + protected void onStatusCreated(Status status){ AccountSessionManager asm = AccountSessionManager.getInstance(); - if(!asm.isSelf(accountID, ev.status.account) || !asm.isSelf(accountID, user)) + if(!asm.isSelf(accountID, status.account) || !asm.isSelf(accountID, user)) return; if(filter==GetAccountStatuses.Filter.PINNED) return; if(filter==GetAccountStatuses.Filter.DEFAULT){ // Keep replies to self, discard all other replies - if(ev.status.inReplyToAccountId!=null && !ev.status.inReplyToAccountId.equals(AccountSessionManager.getInstance().getAccount(accountID).self.id)) + if(status.inReplyToAccountId!=null && !status.inReplyToAccountId.equals(AccountSessionManager.getInstance().getAccount(accountID).self.id)) return; }else if(filter==GetAccountStatuses.Filter.MEDIA){ - if(Optional.ofNullable(ev.status.mediaAttachments).map(List::isEmpty).orElse(true)) + if(Optional.ofNullable(status.mediaAttachments).map(List::isEmpty).orElse(true)) return; } - prependItems(Collections.singletonList(ev.status), true); + prependItems(Collections.singletonList(status), true); if (isOnTop()) scrollToTop(); } @@ -131,8 +126,8 @@ public class AccountTimelineFragment extends StatusListFragment{ @Override - protected Filter.FilterContext getFilterContext() { - return Filter.FilterContext.ACCOUNT; + protected FilterContext getFilterContext() { + return FilterContext.ACCOUNT; } @Override 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 4a787865a..8ff7e9283 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/BaseStatusListFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/BaseStatusListFragment.java @@ -6,14 +6,10 @@ import android.content.res.Configuration; import android.graphics.Canvas; import android.graphics.Paint; import android.graphics.Rect; -import android.graphics.Typeface; import android.graphics.drawable.Drawable; import android.os.Build; import android.os.Bundle; -import android.text.Layout; -import android.text.StaticLayout; -import android.text.TextPaint; -import android.text.TextUtils; +import android.util.Log; import android.view.View; import android.view.ViewGroup; import android.view.WindowInsets; @@ -30,6 +26,7 @@ import org.joinmastodon.android.GlobalUserPreferences; import org.joinmastodon.android.R; import org.joinmastodon.android.api.requests.accounts.GetAccountRelationships; import org.joinmastodon.android.api.requests.polls.SubmitPollVote; +import org.joinmastodon.android.api.session.AccountSessionManager; import org.joinmastodon.android.events.PollUpdatedEvent; import org.joinmastodon.android.model.Account; import org.joinmastodon.android.model.DisplayItemsParent; @@ -37,13 +34,17 @@ import org.joinmastodon.android.model.Poll; import org.joinmastodon.android.model.Relationship; import org.joinmastodon.android.model.Status; import org.joinmastodon.android.ui.BetterItemAnimator; +import org.joinmastodon.android.ui.displayitems.AccountStatusDisplayItem; +import org.joinmastodon.android.ui.displayitems.EmojiReactionsStatusDisplayItem; import org.joinmastodon.android.ui.displayitems.ExtendedFooterStatusDisplayItem; import org.joinmastodon.android.ui.displayitems.FooterStatusDisplayItem; import org.joinmastodon.android.ui.displayitems.GapStatusDisplayItem; +import org.joinmastodon.android.ui.displayitems.HashtagStatusDisplayItem; import org.joinmastodon.android.ui.displayitems.HeaderStatusDisplayItem; import org.joinmastodon.android.ui.displayitems.MediaGridStatusDisplayItem; import org.joinmastodon.android.ui.displayitems.PollFooterStatusDisplayItem; import org.joinmastodon.android.ui.displayitems.PollOptionStatusDisplayItem; +import org.joinmastodon.android.ui.displayitems.SpoilerStatusDisplayItem; import org.joinmastodon.android.ui.displayitems.StatusDisplayItem; import org.joinmastodon.android.ui.displayitems.TextStatusDisplayItem; import org.joinmastodon.android.ui.displayitems.WarningFilteredStatusDisplayItem; @@ -58,6 +59,7 @@ import java.util.ArrayList; import java.util.Collections; import java.util.HashMap; import java.util.List; +import java.util.Objects; import java.util.Set; import java.util.stream.Collectors; @@ -73,10 +75,11 @@ 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.MergeRecyclerAdapter; import me.grishka.appkit.utils.V; import me.grishka.appkit.views.UsableRecyclerView; -public abstract class BaseStatusListFragment extends RecyclerFragment implements PhotoViewerHost, ScrollableToTop, IsOnTop, HasFab, ProvidesAssistContent.ProvidesWebUri { +public abstract class BaseStatusListFragment extends MastodonRecyclerFragment implements PhotoViewerHost, ScrollableToTop, IsOnTop, HasFab, ProvidesAssistContent.ProvidesWebUri { protected ArrayList displayItems=new ArrayList<>(); protected DisplayItemsAdapter adapter; protected String accountID; @@ -101,7 +104,7 @@ public abstract class BaseStatusListFragment exten @Override public void onCreate(Bundle savedInstanceState){ super.onCreate(savedInstanceState); - if(GlobalUserPreferences.disableMarquee){ + if(GlobalUserPreferences.toolbarMarquee){ setTitleMarqueeEnabled(false); setSubtitleMarqueeEnabled(false); } @@ -355,7 +358,11 @@ public abstract class BaseStatusListFragment exten public void getSelectorBounds(View view, Rect outRect){ boolean hasDescendant = false, hasAncestor = false, isWarning = false; int lastIndex = -1, firstIndex = -1; - list.getDecoratedBoundsWithMargins(view, outRect); + if(((UsableRecyclerView) list).isIncludeMarginsInItemHitbox()){ + list.getDecoratedBoundsWithMargins(view, outRect); + }else{ + outRect.set(view.getLeft(), view.getTop(), view.getRight(), view.getBottom()); + } RecyclerView.ViewHolder holder=list.getChildViewHolder(view); if(holder instanceof StatusDisplayItem.Holder){ if(((StatusDisplayItem.Holder) holder).getItem().getType()==StatusDisplayItem.Type.GAP){ @@ -432,6 +439,9 @@ public abstract class BaseStatusListFragment exten } protected int getMainAdapterOffset(){ + if(list.getAdapter() instanceof MergeRecyclerAdapter mergeAdapter){ + return mergeAdapter.getPositionForAdapter(adapter); + } return 0; } @@ -443,6 +453,10 @@ public abstract class BaseStatusListFragment exten c.drawLine(0, y, parent.getWidth(), y, paint); } + protected boolean needDividerForExtraItem(View child, View bottomSibling, RecyclerView.ViewHolder holder, RecyclerView.ViewHolder siblingHolder){ + return false; + } + public abstract void onItemClick(String id); protected void updatePoll(String itemID, Status status, Poll poll){ @@ -530,38 +544,57 @@ public abstract class BaseStatusListFragment exten .exec(accountID); } - public void onRevealSpoilerClick(TextStatusDisplayItem.Holder holder){ + public void onRevealSpoilerClick(SpoilerStatusDisplayItem.Holder holder){ Status status=holder.getItem().status; - revealSpoiler(status, holder.getItemID()); + toggleSpoiler(status, holder.getItemID()); } - public void onRevealSpoilerClick(MediaGridStatusDisplayItem.Holder holder){ - Status status=holder.getItem().status; - revealSpoiler(status, holder.getItemID()); + public void onVisibilityIconClick(HeaderStatusDisplayItem.Holder holder) { + Status status = holder.getItem().status; + MediaGridStatusDisplayItem.Holder mediaGrid = findHolderOfType(holder.getItemID(), MediaGridStatusDisplayItem.Holder.class); + if (mediaGrid != null) { + if (!status.sensitiveRevealed) mediaGrid.revealSensitive(); + else mediaGrid.hideSensitive(); + } else { + // media grid's methods normally change the status' state - we still want to be able + // to do this if the media grid is not bound, tho - so, doing it ourselves here + status.sensitiveRevealed = !status.sensitiveRevealed; + } + holder.rebind(); } - protected void revealSpoiler(Status status, String itemID){ - status.spoilerRevealed=true; + public void onSensitiveRevealed(MediaGridStatusDisplayItem.Holder holder) { + HeaderStatusDisplayItem.Holder header = findHolderOfType(holder.getItemID(), HeaderStatusDisplayItem.Holder.class); + if(header != null) header.rebind(); + } + + protected void toggleSpoiler(Status status, String itemID){ + status.spoilerRevealed=!status.spoilerRevealed; + if (!status.spoilerRevealed && !AccountSessionManager.get(accountID).getLocalPreferences().revealCWs) + status.sensitiveRevealed = false; + + SpoilerStatusDisplayItem.Holder spoiler=findHolderOfType(itemID, SpoilerStatusDisplayItem.Holder.class); + if(spoiler!=null) + spoiler.rebind(); + SpoilerStatusDisplayItem spoilerItem=Objects.requireNonNull(findItemOfType(itemID, SpoilerStatusDisplayItem.class)); + + int index=displayItems.indexOf(spoilerItem); + if(status.spoilerRevealed){ + displayItems.addAll(index+1, spoilerItem.contentItems); + adapter.notifyItemRangeInserted(index+1, spoilerItem.contentItems.size()); + }else{ + displayItems.subList(index+1, index+1+spoilerItem.contentItems.size()).clear(); + adapter.notifyItemRangeRemoved(index+1, spoilerItem.contentItems.size()); + } + TextStatusDisplayItem.Holder text=findHolderOfType(itemID, TextStatusDisplayItem.Holder.class); if(text!=null) adapter.notifyItemChanged(text.getAbsoluteAdapterPosition()-getMainAdapterOffset()); HeaderStatusDisplayItem.Holder header=findHolderOfType(itemID, HeaderStatusDisplayItem.Holder.class); if(header!=null) header.rebind(); - updateImagesSpoilerState(status, itemID); - } - public void onVisibilityIconClick(HeaderStatusDisplayItem.Holder holder){ - Status status=holder.getItem().status; - status.spoilerRevealed=!status.spoilerRevealed; - if(!TextUtils.isEmpty(status.spoilerText)){ - TextStatusDisplayItem.Holder text = findHolderOfType(holder.getItemID(), TextStatusDisplayItem.Holder.class); - if(text!=null){ - adapter.notifyItemChanged(text.getAbsoluteAdapterPosition()); - } - } - holder.rebind(); - updateImagesSpoilerState(status, holder.getItemID()); + list.invalidateItemDecorations(); } public void onEnableExpandable(TextStatusDisplayItem.Holder holder, boolean expandable) { @@ -580,27 +613,12 @@ public abstract class BaseStatusListFragment exten if (header != null) header.rebind(); } - protected void updateImagesSpoilerState(Status status, String itemID){ - ArrayList updatedPositions=new ArrayList<>(); - MediaGridStatusDisplayItem.Holder mediaGrid=findHolderOfType(itemID, MediaGridStatusDisplayItem.Holder.class); - if(mediaGrid!=null){ - mediaGrid.setRevealed(status.spoilerRevealed); - updatedPositions.add(mediaGrid.getAbsoluteAdapterPosition()-getMainAdapterOffset()); - } - int i=0; - for(StatusDisplayItem item:displayItems){ - if(itemID.equals(item.parentID) && item instanceof MediaGridStatusDisplayItem && !updatedPositions.contains(i)){ - adapter.notifyItemChanged(i); - } - i++; - } - } - - public void onImageUpdated(MediaGridStatusDisplayItem.Holder holder, int index) { - holder.rebind(); - MediaGridStatusDisplayItem.Holder mediaGrid = findHolderOfType(holder.getItemID(), MediaGridStatusDisplayItem.Holder.class); - if(mediaGrid!=null){ - adapter.notifyItemChanged(mediaGrid.getAbsoluteAdapterPosition()); + public void updateEmojiReactions(Status status, String itemID){ + EmojiReactionsStatusDisplayItem.Holder reactions=findHolderOfType(itemID, EmojiReactionsStatusDisplayItem.Holder.class); + if(reactions != null){ + reactions.getItem().status.reactions.clear(); + reactions.getItem().status.reactions.addAll(status.reactions); + reactions.rebind(); } } @@ -759,11 +777,28 @@ public abstract class BaseStatusListFragment exten assistContent.setWebUri(getWebUri(getSession().getInstanceUri().buildUpon())); } + public void rebuildAllDisplayItems(){ + displayItems.clear(); + for(T item:data){ + displayItems.addAll(buildDisplayItems(item)); + } + adapter.notifyDataSetChanged(); + } + + protected void onModifyItemViewHolder(BindableViewHolder holder){} + @Override protected void onDataLoaded(List d, boolean more) { + if(getContext()==null) return; super.onDataLoaded(d, more); // more available, but the page isn't even full yet? seems wrong, let's load some more - if (more && d.size() < itemsPerPage) preloader.onScrolledToLastItem(); + if(more && d.size() < itemsPerPage){ + preloader.onScrolledToLastItem(); + } + } + + public void scrollBy(int x, int y) { + list.scrollBy(x, y); } protected class DisplayItemsAdapter extends UsableRecyclerView.Adapter> implements ImageLoaderRecyclerAdapter{ @@ -775,7 +810,9 @@ public abstract class BaseStatusListFragment exten @NonNull @Override public BindableViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType){ - return (BindableViewHolder) StatusDisplayItem.createViewHolder(StatusDisplayItem.Type.values()[viewType & (~0x80000000)], getActivity(), parent); + BindableViewHolder holder=(BindableViewHolder) StatusDisplayItem.createViewHolder(StatusDisplayItem.Type.values()[viewType & (~0x80000000)], getActivity(), parent, BaseStatusListFragment.this); + onModifyItemViewHolder(holder); + return holder; } @Override @@ -806,15 +843,12 @@ public abstract class BaseStatusListFragment exten } private class StatusListItemDecoration extends RecyclerView.ItemDecoration{ - private Paint dividerPaint=new Paint(), hiddenMediaPaint=new Paint(Paint.ANTI_ALIAS_FLAG); - private Typeface mediumTypeface=Typeface.create("sans-serif-medium", Typeface.NORMAL); - private Layout mediaHiddenTitleLayout, mediaHiddenTextLayout, tapToRevealTextLayout; - private int currentMediaHiddenLayoutsWidth=0; + private Paint dividerPaint=new Paint(); { - dividerPaint.setColor(UiUtils.getThemeColor(getActivity(), GlobalUserPreferences.showDividers ? R.attr.colorPollVoted : R.attr.colorWindowBackground)); + dividerPaint.setColor(UiUtils.getThemeColor(getActivity(), GlobalUserPreferences.showDividers ? R.attr.colorM3OutlineVariant : R.attr.colorM3Surface)); dividerPaint.setStyle(Paint.Style.STROKE); - dividerPaint.setStrokeWidth(V.dp(1)); + dividerPaint.setStrokeWidth(V.dp(0.5f)); } @Override @@ -824,80 +858,23 @@ public abstract class BaseStatusListFragment exten View bottomSibling=parent.getChildAt(i+1); RecyclerView.ViewHolder holder=parent.getChildViewHolder(child); RecyclerView.ViewHolder siblingHolder=parent.getChildViewHolder(bottomSibling); - if(holder instanceof StatusDisplayItem.Holder ih && siblingHolder instanceof StatusDisplayItem.Holder sh - && (!ih.getItemID().equals(sh.getItemID()) || sh instanceof ExtendedFooterStatusDisplayItem.Holder) && ih.getItem().getType()!=StatusDisplayItem.Type.GAP){ - if (!ih.getItem().isMainStatus && ih.getItem().hasDescendantNeighbor) continue; + if(needDrawDivider(holder, siblingHolder)){ drawDivider(child, bottomSibling, holder, siblingHolder, parent, c, dividerPaint); } } } - @Override - public void onDrawOver(@NonNull Canvas c, @NonNull RecyclerView parent, @NonNull RecyclerView.State state){ - for(int i=0;i ih && siblingHolder instanceof StatusDisplayItem.Holder sh){ + // Do not draw dividers between hashtag and/or account rows + if((ih instanceof HashtagStatusDisplayItem.Holder || ih instanceof AccountStatusDisplayItem.Holder) && (sh instanceof HashtagStatusDisplayItem.Holder || sh instanceof AccountStatusDisplayItem.Holder)) + return false; + if (!ih.getItem().isMainStatus && ih.getItem().hasDescendantNeighbor) return false; + return (!ih.getItemID().equals(sh.getItemID()) || sh instanceof ExtendedFooterStatusDisplayItem.Holder) && ih.getItem().getType()!=StatusDisplayItem.Type.GAP; } - for(int i=0;i pollOptions=new ArrayList<>(); - - private ArrayList attachments=new ArrayList<>(); + private Button visibilityBtn; + private LinearLayout bottomBar; + private View autocompleteDivider; private List customEmojis; private CustomEmojiPopupKeyboard emojiKeyboard; @@ -219,67 +194,80 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr private Status quote; private String initialText; private String uuid; - private int pollDuration=24*3600; - private String pollDurationStr; private EditText spoilerEdit; + private View spoilerWrap; private boolean hasSpoiler; private boolean sensitive; private Instant scheduledAt = null; private ProgressBar sendProgress; - private ImageView sendError; private View sendingOverlay; private WindowManager wm; private StatusPrivacy statusVisibility=StatusPrivacy.PUBLIC; private boolean localOnly; private ComposeAutocompleteSpan currentAutocompleteSpan; private FrameLayout mainEditTextWrap; - private ComposeAutocompleteViewController autocompleteViewController; - private Instance instance; - private boolean attachmentsErrorShowing; - private Status editingStatus; + private ComposeLanguageAlertViewController.SelectedOption postLang; + + private ComposeAutocompleteViewController autocompleteViewController; + private ComposePollViewController pollViewController=new ComposePollViewController(this); + private ComposeMediaViewController mediaViewController=new ComposeMediaViewController(this); + public Instance instance; + + public Status editingStatus; private ScheduledStatus scheduledStatus; private boolean redraftStatus; - private boolean pollChanged; - private boolean creatingView; - private boolean ignoreSelectionChanges=false; - private Runnable updateUploadEtaRunnable; + private Uri photoUri; - private String language, encoding; private ContentType contentType; private MastodonLanguage.LanguageResolver languageResolver; - private int navigationBarColorBefore; + private boolean creatingView; + private boolean ignoreSelectionChanges=false; + private MenuItem actionItem; + private MenuItem draftMenuItem, undraftMenuItem, scheduleMenuItem, unscheduleMenuItem; + private boolean wasDetached; + + private BackgroundColorSpan overLimitBG; + private ForegroundColorSpan overLimitFG; + + public ComposeFragment(){ + super(R.layout.toolbar_fragment_with_progressbar); + } @Override public void onCreate(Bundle savedInstanceState){ super.onCreate(savedInstanceState); E.register(this); setRetainInstance(true); - navigationBarColorBefore = getActivity().getWindow().getNavigationBarColor(); - getActivity().getWindow().setNavigationBarColor(UiUtils.getThemeColor(getActivity(), R.attr.colorBackgroundLightest)); accountID=getArguments().getString("account"); - contentType = GlobalUserPreferences.accountsDefaultContentTypes.get(accountID); + AccountSession session=AccountSessionManager.get(accountID); - AccountSession session=AccountSessionManager.getInstance().getAccount(accountID); self=session.self; instanceDomain=session.domain; customEmojis=AccountSessionManager.getInstance().getCustomEmojis(instanceDomain); instance=AccountSessionManager.getInstance().getInstanceInfo(instanceDomain); languageResolver=new MastodonLanguage.LanguageResolver(instance); redraftStatus=getArguments().getBoolean("redraftStatus", false); - if(getArguments().containsKey("editStatus")) + contentType=session.getLocalPreferences().defaultContentType; + if(getArguments().containsKey("editStatus")){ editingStatus=Parcels.unwrap(getArguments().getParcelable("editStatus")); - if(getArguments().containsKey("replyTo")) + } + if(getArguments().containsKey("replyTo")) { replyTo=Parcels.unwrap(getArguments().getParcelable("replyTo")); - if(getArguments().containsKey("quote")) + } + if(getArguments().containsKey("quote")) { quote=Parcels.unwrap(getArguments().getParcelable("quote")); + } if(instance==null){ Nav.finish(this); return; } + if(customEmojis.isEmpty()){ + AccountSessionManager.getInstance().updateInstanceInfo(instanceDomain); + } Bundle bundle = savedInstanceState != null ? savedInstanceState : getArguments(); if (bundle.containsKey("scheduledStatus")) scheduledStatus=Parcels.unwrap(bundle.getParcelable("scheduledStatus")); @@ -291,21 +279,17 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr charLimit=instance.configuration.statuses.maxCharacters; else charLimit=500; + +// setTitle(editingStatus==null ? R.string.new_post : R.string.edit_post); + if(savedInstanceState!=null) + postLang=Parcels.unwrap(savedInstanceState.getParcelable("postLang")); } @Override public void onDestroy(){ super.onDestroy(); E.unregister(this); - for(DraftMediaAttachment att:attachments){ - if(att.isUploadingOrProcessing()) - att.cancelUpload(); - } - if(updateUploadEtaRunnable!=null){ - UiUtils.removeCallbacks(updateUploadEtaRunnable); - updateUploadEtaRunnable=null; - } - getActivity().getWindow().setNavigationBarColor(navigationBarColorBefore); + mediaViewController.cancelAllUploads(); } @Override @@ -313,6 +297,15 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr super.onAttach(activity); setHasOptionsMenu(true); wm=activity.getSystemService(WindowManager.class); + + overLimitBG=new BackgroundColorSpan(UiUtils.getThemeColor(activity, R.attr.colorM3ErrorContainer)); + overLimitFG=new ForegroundColorSpan(UiUtils.getThemeColor(activity, R.attr.colorM3Error)); + } + + @Override + public void onDetach(){ + wasDetached=true; + super.onDetach(); } @SuppressLint("ClickableViewAccessibility") @@ -320,7 +313,25 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr public View onCreateContentView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState){ creatingView=true; emojiKeyboard=new CustomEmojiPopupKeyboard(getActivity(), customEmojis, instanceDomain); - emojiKeyboard.setListener(this::onCustomEmojiClick); + emojiKeyboard.setListener(new CustomEmojiPopupKeyboard.Listener(){ + @Override + public void onEmojiSelected(Emoji emoji){ + onCustomEmojiClick(emoji); + } + + @Override + public void onEmojiSelected(String emoji){ + if(getActivity().getCurrentFocus() instanceof EditText edit && edit == mainEditText){ + edit.getText().replace(edit.getSelectionStart(), edit.getSelectionEnd(), emoji); + } + } + + @Override + public void onBackspace(){ + getActivity().dispatchKeyEvent(new KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_DEL)); + getActivity().dispatchKeyEvent(new KeyEvent(KeyEvent.ACTION_UP, KeyEvent.KEYCODE_DEL)); + } + }); View view=inflater.inflate(R.layout.fragment_compose, container, false); @@ -340,6 +351,7 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr charCounter.setText(String.valueOf(charLimit)); } + mainLayout=view.findViewById(R.id.compose_main_ll); mainEditText=view.findViewById(R.id.toot_text); mainEditTextWrap=view.findViewById(R.id.toot_text_wrap); scrollView=view.findViewById(R.id.scroll_view); @@ -350,7 +362,8 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr selfExtraText=view.findViewById(R.id.self_extra_text); HtmlParser.setTextWithCustomEmoji(selfName, self.displayName, self.emojis); selfUsername.setText('@'+self.username+'@'+instanceDomain); - ViewImageLoader.load(selfAvatar, null, new UrlImageLoaderRequest(self.avatar)); + if(self.avatar!=null) + ViewImageLoader.load(selfAvatar, null, new UrlImageLoaderRequest(self.avatar)); ViewOutlineProvider roundCornersOutline=new ViewOutlineProvider(){ @Override public void getOutline(View view, Outline outline){ @@ -359,6 +372,7 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr }; selfAvatar.setOutlineProvider(roundCornersOutline); selfAvatar.setClipToOutline(true); + bottomBar=view.findViewById(R.id.bottom_bar); mediaBtn=view.findViewById(R.id.btn_media); pollBtn=view.findViewById(R.id.btn_poll); @@ -370,11 +384,8 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr scheduleDraftText=view.findViewById(R.id.schedule_draft_text); scheduleDraftDismiss=view.findViewById(R.id.schedule_draft_dismiss); scheduleTimeBtn=view.findViewById(R.id.scheduled_time_btn); - sensitiveIcon=view.findViewById(R.id.sensitive_icon); - sensitiveItem=view.findViewById(R.id.sensitive_item); - replyText=view.findViewById(GlobalUserPreferences.replyLineAboveHeader ? R.id.reply_text : R.id.reply_text_below); - view.findViewById(GlobalUserPreferences.replyLineAboveHeader ? R.id.reply_text_below : R.id.reply_text) - .setVisibility(View.GONE); + sensitiveBtn=view.findViewById(R.id.sensitive_item); + replyText=view.findViewById(R.id.reply_text); PopupMenu attachPopup = new PopupMenu(getContext(), mediaBtn); attachPopup.inflate(R.menu.attach); @@ -401,6 +412,9 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr pollBtn.setOnClickListener(v->togglePoll()); emojiBtn.setOnClickListener(v->emojiKeyboard.toggleKeyboardPopup(mainEditText)); spoilerBtn.setOnClickListener(v->toggleSpoiler()); + Drawable arrow=getResources().getDrawable(R.drawable.ic_baseline_arrow_drop_down_18, getActivity().getTheme()).mutate(); + arrow.setTint(UiUtils.getThemeColor(getActivity(), R.attr.colorM3OnSurface)); + visibilityBtn.setCompoundDrawablesRelativeWithIntrinsicBounds(null, null, arrow, null); localOnly = savedInstanceState != null ? savedInstanceState.getBoolean("localOnly") : editingStatus != null ? editingStatus.localOnly : replyTo != null && replyTo.localOnly; @@ -416,79 +430,40 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr scheduleDraftDismiss.setOnClickListener(v->updateScheduledAt(null)); scheduleTimeBtn.setOnClickListener(v->pickScheduledDateTime()); - sensitiveItem.setOnClickListener(v->toggleSensitive()); + sensitiveBtn.setOnClickListener(v->toggleSensitive()); emojiKeyboard.setOnIconChangedListener(new PopupKeyboard.OnIconChangeListener(){ @Override public void onIconChanged(int icon){ emojiBtn.setSelected(icon!=PopupKeyboard.ICON_HIDDEN); + updateNavigationBarColor(icon!=PopupKeyboard.ICON_HIDDEN); + if(autocompleteViewController.getMode()==ComposeAutocompleteViewController.Mode.EMOJIS){ + contentView.layout(contentView.getLeft(), contentView.getTop(), contentView.getRight(), contentView.getBottom()); + if(icon==PopupKeyboard.ICON_HIDDEN) + showAutocomplete(); + else + hideAutocomplete(); + } } }); contentView=(SizeListenerLinearLayout) view; contentView.addView(emojiKeyboard.getView()); - emojiKeyboard.getView().setElevation(V.dp(2)); - - attachmentsView=view.findViewById(R.id.attachments); - pollOptionsView=view.findViewById(R.id.poll_options); - pollWrap=view.findViewById(R.id.poll_wrap); - addPollOptionBtn=view.findViewById(R.id.add_poll_option); - pollAllowMultipleItem=view.findViewById(R.id.poll_allow_multiple); - pollAllowMultipleCheckbox=view.findViewById(R.id.poll_allow_multiple_checkbox); - pollAllowMultipleItem.setOnClickListener(v->this.togglePollAllowMultiple()); - - addPollOptionBtn.setOnClickListener(v->{ - createDraftPollOption().edit.requestFocus(); - updatePollOptionHints(); - }); - pollOptionsView.setDragListener(this::onSwapPollOptions); - pollDurationView=view.findViewById(R.id.poll_duration); - pollDurationView.setOnClickListener(v->showPollDurationMenu()); - - pollOptions.clear(); - if(savedInstanceState!=null && savedInstanceState.containsKey("pollOptions")){ - pollBtn.setSelected(true); - mediaBtn.setEnabled(false); - pollWrap.setVisibility(View.VISIBLE); - updatePollAllowMultiple(savedInstanceState.getBoolean("pollAllowMultiple", false)); - for(String oldText:savedInstanceState.getStringArrayList("pollOptions")){ - DraftPollOption opt=createDraftPollOption(); - opt.edit.setText(oldText); - } - updatePollOptionHints(); - pollDurationView.setText(getString(R.string.compose_poll_duration, pollDurationStr)); - }else if(savedInstanceState==null && editingStatus!=null && editingStatus.poll!=null){ - pollBtn.setSelected(true); - mediaBtn.setEnabled(false); - pollWrap.setVisibility(View.VISIBLE); - updatePollAllowMultiple(editingStatus.poll.multiple); - for(Poll.Option eopt:editingStatus.poll.options){ - DraftPollOption opt=createDraftPollOption(); - opt.edit.setText(eopt.title); - } - pollDuration=scheduledStatus == null - ? (int)editingStatus.poll.expiresAt.minus(System.currentTimeMillis(), ChronoUnit.MILLIS).getEpochSecond() - : Integer.parseInt(scheduledStatus.params.poll.expiresIn); - pollDurationStr=UiUtils.formatTimeLeft(getActivity(), scheduledStatus == null - ? editingStatus.poll.expiresAt - : Instant.now().plus(pollDuration, ChronoUnit.SECONDS)); - updatePollOptionHints(); - pollDurationView.setText(getString(R.string.compose_poll_duration, pollDurationStr)); - }else{ - pollDurationView.setText(getString(R.string.compose_poll_duration, pollDurationStr=getResources().getQuantityString(R.plurals.x_days, 1, 1))); - } spoilerEdit=view.findViewById(R.id.content_warning); - LayerDrawable spoilerBg=(LayerDrawable) spoilerEdit.getBackground().mutate(); - spoilerBg.setDrawableByLayerId(R.id.left_drawable, new SpoilerStripesDrawable()); - spoilerBg.setDrawableByLayerId(R.id.right_drawable, new SpoilerStripesDrawable()); - spoilerEdit.setBackground(spoilerBg); + spoilerWrap=view.findViewById(R.id.content_warning_wrap); + LayerDrawable spoilerBg=(LayerDrawable) spoilerWrap.getBackground().mutate(); + spoilerBg.setDrawableByLayerId(R.id.left_drawable, new SpoilerStripesDrawable(false)); + spoilerBg.setDrawableByLayerId(R.id.right_drawable, new SpoilerStripesDrawable(false)); + spoilerWrap.setBackground(spoilerBg); + spoilerWrap.setClipToOutline(true); + spoilerWrap.setOutlineProvider(OutlineProviders.roundedRect(8)); if((savedInstanceState!=null && savedInstanceState.getBoolean("hasSpoiler", false)) || hasSpoiler){ hasSpoiler=true; - spoilerEdit.setVisibility(View.VISIBLE); + spoilerWrap.setVisibility(View.VISIBLE); spoilerBtn.setSelected(true); }else if(editingStatus!=null && !TextUtils.isEmpty(editingStatus.spoilerText)){ hasSpoiler=true; - spoilerEdit.setVisibility(View.VISIBLE); + spoilerWrap.setVisibility(View.VISIBLE); spoilerEdit.setText(getArguments().getString("sourceSpoiler", editingStatus.spoilerText)); spoilerBtn.setSelected(true); } @@ -496,23 +471,8 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr sensitive = savedInstanceState==null && editingStatus != null ? editingStatus.sensitive : savedInstanceState!=null && savedInstanceState.getBoolean("sensitive", false); if (sensitive) { - sensitiveItem.setVisibility(View.VISIBLE); - sensitiveIcon.setSelected(true); - } - - if(savedInstanceState!=null && savedInstanceState.containsKey("attachments")){ - ArrayList serializedAttachments=savedInstanceState.getParcelableArrayList("attachments"); - for(Parcelable a:serializedAttachments){ - DraftMediaAttachment att=Parcels.unwrap(a); - attachmentsView.addView(createMediaAttachmentView(att)); - attachments.add(att); - } - attachmentsView.setVisibility(View.VISIBLE); - }else if(!attachments.isEmpty()){ - attachmentsView.setVisibility(View.VISIBLE); - for(DraftMediaAttachment att:attachments){ - attachmentsView.addView(createMediaAttachmentView(att)); - } + sensitiveBtn.setVisibility(View.VISIBLE); + sensitiveBtn.setSelected(true); } if (savedInstanceState != null) { @@ -533,25 +493,46 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr }).setChecked(true); visibilityPopup.getMenu().findItem(R.id.local_only).setChecked(localOnly); - if (savedInstanceState != null && savedInstanceState.containsKey("contentType")) { contentType = (ContentType) savedInstanceState.getSerializable("contentType"); } else if (getArguments().containsKey("sourceContentType")) { try { String val = getArguments().getString("sourceContentType"); - contentType = val == null ? null : ContentType.valueOf(val); + if (val != null) contentType = ContentType.valueOf(val); } catch (IllegalArgumentException ignored) {} } - int contentTypeId = ContentType.getContentTypeRes(contentType); - contentTypePopup.getMenu().findItem(contentTypeId).setChecked(true); - contentTypeBtn.setSelected(contentTypeId != R.id.content_type_null && contentTypeId != R.id.content_type_plain); + int typeIndex=contentType.ordinal(); + contentTypePopup.getMenu().findItem(typeIndex).setChecked(true); + contentTypeBtn.setSelected(typeIndex != ContentType.UNSPECIFIED.ordinal() && typeIndex != ContentType.PLAIN.ordinal()); autocompleteViewController=new ComposeAutocompleteViewController(getActivity(), accountID); - autocompleteViewController.setCompletionSelectedListener(this::onAutocompleteOptionSelected); + autocompleteViewController.setCompletionSelectedListener(new ComposeAutocompleteViewController.AutocompleteListener(){ + @Override + public void onCompletionSelected(String completion){ + onAutocompleteOptionSelected(completion); + } + + @Override + public void onSetEmojiPanelOpen(boolean open){ + if(open!=emojiKeyboard.isVisible()) + emojiKeyboard.toggleKeyboardPopup(mainEditText); + } + + @Override + public void onLaunchAccountSearch(){ + Bundle args=new Bundle(); + args.putString("account", accountID); + Nav.goForResult(getActivity(), ComposeAccountSearchFragment.class, args, AUTOCOMPLETE_ACCOUNT_RESULT, ComposeFragment.this); + } + }); View autocompleteView=autocompleteViewController.getView(); - autocompleteView.setVisibility(View.GONE); - mainEditTextWrap.addView(autocompleteView, new FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, V.dp(178), Gravity.TOP)); + autocompleteView.setVisibility(View.INVISIBLE); + bottomBar.addView(autocompleteView, 0, new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, V.dp(56))); + autocompleteDivider=view.findViewById(R.id.bottom_bar_autocomplete_divider); + + pollViewController.setView(view, savedInstanceState); + mediaViewController.setView(view, savedInstanceState); creatingView=false; @@ -561,31 +542,16 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr @Override public void onSaveInstanceState(Bundle outState){ super.onSaveInstanceState(outState); - if(!pollOptions.isEmpty()){ - ArrayList opts=new ArrayList<>(); - for(DraftPollOption opt:pollOptions){ - opts.add(opt.edit.getText().toString()); - } - outState.putStringArrayList("pollOptions", opts); - outState.putInt("pollDuration", pollDuration); - outState.putString("pollDurationStr", pollDurationStr); - outState.putBoolean("pollAllowMultiple", pollAllowMultipleItem.isSelected()); - } - outState.putBoolean("sensitive", sensitive); - outState.putBoolean("localOnly", localOnly); + pollViewController.onSaveInstanceState(outState); + mediaViewController.onSaveInstanceState(outState); outState.putBoolean("hasSpoiler", hasSpoiler); - outState.putString("language", language); - if(!attachments.isEmpty()){ - ArrayList serializedAttachments=new ArrayList<>(attachments.size()); - for(DraftMediaAttachment att:attachments){ - serializedAttachments.add(Parcels.wrap(att)); - } - outState.putParcelableArrayList("attachments", serializedAttachments); - } outState.putSerializable("visibility", statusVisibility); - outState.putSerializable("contentType", contentType); - if (scheduledAt != null) outState.putSerializable("scheduledAt", scheduledAt); - if (scheduledStatus != null) outState.putParcelable("scheduledStatus", Parcels.wrap(scheduledStatus)); + outState.putParcelable("postLang", Parcels.wrap(postLang)); + if(currentAutocompleteSpan!=null){ + Editable e=mainEditText.getText(); + outState.putInt("autocompleteStart", e.getSpanStart(currentAutocompleteSpan)); + outState.putInt("autocompleteEnd", e.getSpanEnd(currentAutocompleteSpan)); + } } @@ -612,9 +578,11 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr contentView.setSizeListener(emojiKeyboard::onContentViewSizeChanged); InputMethodManager imm=getActivity().getSystemService(InputMethodManager.class); mainEditText.requestFocus(); - view.postDelayed(()->{ - imm.showSoftInput(mainEditText, 0); - }, 100); + view.postDelayed(()->{ + imm.showSoftInput(mainEditText, 0); + }, 100); + sendProgress=view.findViewById(R.id.progress); + sendProgress.setVisibility(View.GONE); mainEditText.setSelectionListener(this); mainEditText.addTextChangedListener(new TextWatcher(){ @@ -678,7 +646,6 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr char prevChar=spanStart>0 ? editable.charAt(spanStart-1) : ' '; if(!matcher.find() || !Character.isWhitespace(prevChar)){ // invalid mention, remove editable.removeSpan(span); - continue; }else if(matcher.end()+spanStart{ Bundle args=new Bundle(); @@ -741,9 +709,6 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr Nav.go(getActivity(), ProfileFragment.class, args); }); - ((TextView) view.findViewById(R.id.name)).setText(status.account.displayName); - ((TextView) view.findViewById(R.id.username)).setText(status.account.getDisplayUsername()); - view.findViewById(R.id.visibility).setVisibility(View.GONE); Drawable visibilityIcon = getActivity().getDrawable(switch(status.visibility){ case PUBLIC -> R.drawable.ic_fluent_earth_20_regular; case UNLISTED -> R.drawable.ic_fluent_lock_open_20_regular; @@ -754,19 +719,40 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr ImageView moreBtn = view.findViewById(R.id.more); moreBtn.setImageDrawable(visibilityIcon); moreBtn.setBackground(null); - TextView timestamp = view.findViewById(R.id.timestamp); - if (status.editedAt!=null) timestamp.setText(getString(R.string.edited_timestamp, UiUtils.formatRelativeTimestamp(getContext(), status.editedAt))); - else if (status.createdAt!=null) timestamp.setText(UiUtils.formatRelativeTimestamp(getContext(), status.createdAt)); - else timestamp.setText(""); + + TextView name = view.findViewById(R.id.name); + name.setText(HtmlParser.parseCustomEmoji(status.account.displayName, status.account.emojis)); + UiUtils.loadCustomEmojiInTextView(name); + + String time = status==null || status.editedAt==null + ? UiUtils.formatRelativeTimestamp(getContext(), status.createdAt) + : getString(R.string.edited_timestamp, UiUtils.formatRelativeTimestamp(getContext(), status.editedAt)); + + String sepp = getString(R.string.sk_separator); + String username = status.account.getDisplayUsername(); + ((TextView) view.findViewById(R.id.time_and_username)).setText(time == null ? username : + username + " " + sepp + " " + time); if (status.spoilerText != null && !status.spoilerText.isBlank()) { - view.findViewById(R.id.spoiler_header).setVisibility(View.VISIBLE); - ((TextView) view.findViewById(R.id.spoiler_title_inline)).setText(status.spoilerText); + TextView replyToSpoiler = view.findViewById(R.id.reply_to_spoiler); + replyToSpoiler.setVisibility(View.VISIBLE); + replyToSpoiler.setText(status.spoilerText); + LayerDrawable spoilerBg=(LayerDrawable) replyToSpoiler.getBackground().mutate(); + spoilerBg.setDrawableByLayerId(R.id.left_drawable, new SpoilerStripesDrawable(false)); + spoilerBg.setDrawableByLayerId(R.id.right_drawable, new SpoilerStripesDrawable(false)); + replyToSpoiler.setBackground(spoilerBg); + replyToSpoiler.setClipToOutline(true); + replyToSpoiler.setOutlineProvider(OutlineProviders.roundedRect(8)); } SpannableStringBuilder content = HtmlParser.parse(status.content, status.emojis, status.mentions, status.tags, accountID); LinkedTextView text = view.findViewById(R.id.text); - if (content.length() > 0) text.setText(content); - else view.findViewById(R.id.display_item_text).setLayoutParams(new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, V.dp(16))); + if (content.length() > 0) { + text.setText(content); + UiUtils.loadCustomEmojiInTextView(text); + } else { + view.findViewById(R.id.display_item_text) + .setLayoutParams(new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, V.dp(16))); + } replyText.setText(getString(quote!=null? R.string.sk_quoting_user : R.string.in_reply_to, status.account.displayName)); int visibilityNameRes = switch (status.visibility) { @@ -776,7 +762,7 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr case DIRECT -> R.string.visibility_private; case LOCAL -> R.string.sk_local_only; }; - replyText.setContentDescription(getString(R.string.in_reply_to, status.account.displayName) + ". " + getString(R.string.post_visibility) + ": " + getString(visibilityNameRes)); + replyText.setContentDescription(getString(R.string.in_reply_to, status.account.displayName) + ", " + getString(visibilityNameRes)); replyText.setOnClickListener(v->{ scrollView.smoothScrollTo(0, 0); }); @@ -806,20 +792,16 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr ignoreSelectionChanges=false; if(!TextUtils.isEmpty(status.spoilerText)){ hasSpoiler=true; - spoilerEdit.setVisibility(View.VISIBLE); - if ((GlobalUserPreferences.prefixReplies == ALWAYS + spoilerWrap.setVisibility(View.VISIBLE); + String prefix = (GlobalUserPreferences.prefixReplies == ALWAYS || (GlobalUserPreferences.prefixReplies == TO_OTHERS && !ownID.equals(status.account.id))) - && !status.spoilerText.startsWith("re: ")) { - spoilerEdit.setText("re: " + status.spoilerText); - } else { - spoilerEdit.setText(status.spoilerText); - } + && !status.spoilerText.startsWith("re: ") ? "re: " : ""; + spoilerEdit.setText(prefix + replyTo.spoilerText); spoilerBtn.setSelected(true); } - if (status.language != null && !status.language.isEmpty()) updateLanguage(status.language); + if (status.language != null && !status.language.isEmpty()) setPostLanguage(status.language); } }else if (editingStatus==null || editingStatus.inReplyToId==null){ - // TODO: remove workaround after https://github.com/mastodon/mastodon-android/issues/341 gets fixed replyText.setVisibility(View.GONE); } if(savedInstanceState==null){ @@ -829,20 +811,8 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr ignoreSelectionChanges=true; mainEditText.setSelection(mainEditText.length()); ignoreSelectionChanges=false; - updateLanguage(editingStatus.language); - if(!editingStatus.mediaAttachments.isEmpty()){ - attachmentsView.setVisibility(View.VISIBLE); - for(Attachment att:editingStatus.mediaAttachments){ - DraftMediaAttachment da=new DraftMediaAttachment(); - da.serverAttachment=att; - da.description=att.description; - da.uri=att.previewUrl!=null ? Uri.parse(att.previewUrl) : null; - da.state=AttachmentUploadState.DONE; - attachmentsView.addView(createMediaAttachmentView(da)); - attachments.add(da); - } - pollBtn.setEnabled(false); - } + setPostLanguage(editingStatus.language); + mediaViewController.onViewCreated(savedInstanceState);; }else{ String prefilledText=getArguments().getString("prefilledText"); if(!TextUtils.isEmpty(prefilledText)){ @@ -860,7 +830,7 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr ArrayList mediaUris=getArguments().getParcelableArrayList("mediaAttachments"); if(mediaUris!=null && !mediaUris.isEmpty()){ for(Uri uri:mediaUris){ - addMediaAttachment(uri, null); + mediaViewController.addMediaAttachment(uri, null); } } } @@ -873,15 +843,28 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr updateCharCounter(); visibilityBtn.setEnabled(redraftStatus); } + updateMediaPollStates(); + } + + @Override + public void onViewStateRestored(Bundle savedInstanceState){ + super.onViewStateRestored(savedInstanceState); + if(savedInstanceState!=null && savedInstanceState.containsKey("autocompleteStart")){ + int start=savedInstanceState.getInt("autocompleteStart"), end=savedInstanceState.getInt("autocompleteEnd"); + currentAutocompleteSpan=new ComposeAutocompleteSpan(); + mainEditText.getText().setSpan(currentAutocompleteSpan, start, end, Editable.SPAN_EXCLUSIVE_INCLUSIVE); + startAutocomplete(currentAutocompleteSpan); + } } @Override public void onCreateOptionsMenu(Menu menu, MenuInflater inflater){ - MenuItem item=menu.add(editingStatus==null ? R.string.publish : R.string.save); + inflater.inflate(editingStatus==null ? R.menu.compose : R.menu.compose_edit, menu); + actionItem = menu.findItem(R.id.publish); LinearLayout wrap=new LinearLayout(getActivity()); getActivity().getLayoutInflater().inflate(R.layout.compose_action, wrap); - item.setActionView(wrap); - item.setShowAsAction(MenuItem.SHOW_AS_ACTION_ALWAYS); + actionItem.setActionView(wrap); + actionItem.setShowAsAction(MenuItem.SHOW_AS_ACTION_ALWAYS); if(!GlobalUserPreferences.relocatePublishButton){ publishButton = wrap.findViewById(R.id.publish_btn); @@ -915,24 +898,31 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr languageButton = wrap.findViewById(R.id.language_btn); - sendProgress = wrap.findViewById(R.id.send_progress); - sendError = wrap.findViewById(R.id.send_error); + languageButton.setOnClickListener(v->showLanguageAlert()); + publishButton.setOnClickListener(v -> { + if(GlobalUserPreferences.altTextReminders && editingStatus==null) + checkAltTextsAndPublish(); + else + publish(); + }); draftsBtn.setOnClickListener(v-> draftOptionsPopup.show()); draftsBtn.setOnTouchListener(draftOptionsPopup.getDragToOpenListener()); updateScheduledAt(scheduledAt != null ? scheduledAt : scheduledStatus != null ? scheduledStatus.scheduledAt : null); - buildLanguageSelector(languageButton); + + Preferences prefs = AccountSessionManager.get(accountID).preferences; + if (postLang != null) setPostLanguage(postLang); + else setPostLanguage(prefs != null && prefs.postingDefaultLanguage != null && prefs.postingDefaultLanguage.length() > 0 + ? languageResolver.fromOrFallback(prefs.postingDefaultLanguage) + : languageResolver.getDefault()); if (isInstancePixelfed()) spoilerBtn.setVisibility(View.GONE); if (isInstancePixelfed() || (editingStatus != null && scheduledStatus == null)) { // editing an already published post draftsBtn.setVisibility(View.GONE); } - } - @Override - public String getAccountID() { - return accountID; + updatePublishButtonState(); } private void navigateToUnsentPosts() { @@ -951,86 +941,6 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr } } - private void updateLanguage(String lang) { - updateLanguage(lang == null ? languageResolver.getDefault() : languageResolver.from(lang)); - } - - private void updateLanguage(MastodonLanguage loc) { - 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") - private void buildLanguageSelector(Button btn) { - languagePopup=new PopupMenu(getActivity(), languageButton); - btn.setOnTouchListener(languagePopup.getDragToOpenListener()); - btn.setOnClickListener(v->languagePopup.show()); - - Preferences prefs = AccountSessionManager.getInstance().getAccount(accountID).preferences; - 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)) { - 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); - for (int i = 0; i < allLanguages.size(); i++) { - MastodonLanguage l = allLanguages.get(i); - 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; - 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 int getContentTypeName(String id) { - return switch (id) { - case "text/plain" -> R.string.sk_content_type_plain; - case "text/html" -> R.string.sk_content_type_html; - case "text/markdown" -> R.string.sk_content_type_markdown; - case "text/bbcode" -> R.string.sk_content_type_bbcode; - case "text/x.misskeymarkdown" -> R.string.sk_content_type_mfm; - default -> throw new IllegalArgumentException("Invalid content type"); - }; - } - - 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; @@ -1044,7 +954,7 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr @SuppressLint("NewApi") private void updateCharCounter(){ - CharSequence text=mainEditText.getText(); + Editable text=mainEditText.getText(); String countableText=TwitterTextEmojiRegex.VALID_EMOJI_PATTERN.matcher( MENTION_PATTERN.matcher( @@ -1060,10 +970,23 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr if(hasSpoiler){ charCount+=spoilerEdit.length(); } - if (localOnly && GlobalUserPreferences.accountsInGlitchMode.contains(accountID)) { + if (localOnly && AccountSessionManager.get(accountID).getLocalPreferences().glitchInstance) { charCount -= GLITCH_LOCAL_ONLY_SUFFIX.length(); } charCounter.setText(String.valueOf(charLimit-charCount)); + + text.removeSpan(overLimitBG); + text.removeSpan(overLimitFG); + if(charCount>charLimit){ + charCounter.setTextColor(UiUtils.getThemeColor(getActivity(), R.attr.colorM3Error)); + int start=text.length()-(charCount-charLimit); + int end=text.length(); + text.setSpan(overLimitFG, start, end, 0); + text.setSpan(overLimitBG, start, end, 0); + }else{ + charCounter.setTextColor(UiUtils.getThemeColor(getActivity(), R.attr.colorM3OnSurface)); + } + trimmedCharCount=text.toString().trim().length(); updatePublishButtonState(); } @@ -1075,31 +998,31 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr } if (publishText == R.string.publish && !GlobalUserPreferences.publishButtonText.isEmpty()) { publishButton.setText(GlobalUserPreferences.publishButtonText); + AccountLocalPreferences prefs=AccountSessionManager.get(accountID).getLocalPreferences(); + if (publishText == R.string.publish && !TextUtils.isEmpty(prefs.publishButtonText)) { + publishButton.setText(prefs.publishButtonText); } else { publishButton.setText(publishText); } } - private void updatePublishButtonState(){ + public void updatePublishButtonState(){ uuid=null; - int nonEmptyPollOptionsCount=0; - for(DraftPollOption opt:pollOptions){ - if(opt.edit.length()>0) - nonEmptyPollOptionsCount++; - } if(publishButton==null) return; - int nonDoneAttachmentCount=0; - for(DraftMediaAttachment att:attachments){ - if(att.state!=AttachmentUploadState.DONE) - nonDoneAttachmentCount++; - } - publishButton.setEnabled((!isInstancePixelfed() || attachments.size() > 0) && (trimmedCharCount>0 || !attachments.isEmpty()) && charCount<=charLimit && nonDoneAttachmentCount==0 && (pollOptions.isEmpty() || nonEmptyPollOptionsCount>1)); - sendError.setVisibility(View.GONE); + publishButton.setEnabled((!isInstancePixelfed() || !mediaViewController.isEmpty()) && (trimmedCharCount>0 || !mediaViewController.isEmpty()) && charCount<=charLimit && mediaViewController.getNonDoneAttachmentCount()==0 && (pollViewController.isEmpty() || pollViewController.getNonEmptyOptionsCount()>1)); } private void onCustomEmojiClick(Emoji emoji){ if(getActivity().getCurrentFocus() instanceof EditText edit){ + if(edit==mainEditText && currentAutocompleteSpan!=null && autocompleteViewController.getMode()==ComposeAutocompleteViewController.Mode.EMOJIS){ + Editable text=mainEditText.getText(); + int start=text.getSpanStart(currentAutocompleteSpan); + int end=text.getSpanEnd(currentAutocompleteSpan); + finishAutocomplete(); + text.replace(start, end, ':'+emoji.shortcode+':'); + return; + } int start=edit.getSelectionStart(); String prefix=start>0 && !Character.isWhitespace(edit.getText().charAt(start-1)) ? " :" : ":"; edit.getText().replace(start, edit.getSelectionEnd(), prefix+emoji.shortcode+':'); @@ -1109,20 +1032,26 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr @Override protected void updateToolbar(){ super.updateToolbar(); - getToolbar().setNavigationIcon(R.drawable.ic_fluent_dismiss_24_regular); + int color=UiUtils.alphaBlendThemeColors(getActivity(), R.attr.colorM3Background, R.attr.colorM3Primary, 0.11f); + getToolbar().setBackgroundColor(color); + setStatusBarColor(color); + bottomBar.setBackgroundColor(color); + updateNavigationBarColor(emojiKeyboard.isVisible()); } - private void onPublishClick(View v){ - publish(); + private void updateNavigationBarColor(boolean emojiKeyboardVisible){ + int color=UiUtils.alphaBlendThemeColors(getActivity(), R.attr.colorM3Background, R.attr.colorM3Primary, emojiKeyboardVisible ? 0.08f : 0.11f); + setNavigationBarColor(color); } - private void publishErrorCallback(ErrorResponse error) { - wm.removeView(sendingOverlay); - sendingOverlay=null; - sendProgress.setVisibility(View.GONE); - sendError.setVisibility(View.VISIBLE); - publishButton.setEnabled(true); - if (error != null) error.showToast(getActivity()); + @Override + protected int getNavigationIconDrawableResource(){ + return R.drawable.ic_baseline_close_24; + } + + @Override + public boolean wantsCustomNavigationIcon(){ + return true; } private void createScheduledStatusFinish(ScheduledStatus result) { @@ -1145,7 +1074,7 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr @Override public void onError(ErrorResponse error) { - publishErrorCallback(error); + handlePublishError(error); } }).exec(accountID); } else { @@ -1153,75 +1082,29 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr } } - private void publish(){ - publish(false); + private void checkAltTextsAndPublish(){ + int count=mediaViewController.getMissingAltTextAttachmentCount(); + if(count==0){ + publish(); + }else{ + String msg=getResources().getQuantityString(mediaViewController.areAllAttachmentsImages() ? R.plurals.alt_text_reminder_x_images : R.plurals.alt_text_reminder_x_attachments, + count, switch(count){ + case 1 -> getString(R.string.count_one); + case 2 -> getString(R.string.count_two); + case 3 -> getString(R.string.count_three); + case 4 -> getString(R.string.count_four); + default -> String.valueOf(count); + }); + new M3AlertDialogBuilder(getActivity()) + .setTitle(R.string.alt_text_reminder_title) + .setMessage(msg) + .setPositiveButton(R.string.alt_text_reminder_post_anyway, (dlg, item)->publish()) + .setNegativeButton(R.string.cancel, null) + .show(); + } } - 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.localOnly=localOnly; - req.visibility=localOnly && instance.isAkkoma() ? StatusPrivacy.LOCAL : statusVisibility; - req.sensitive=sensitive; - req.language=language; - req.contentType=contentType; - 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; - } - if(!pollOptions.isEmpty()){ - req.poll=new CreateStatus.Request.Poll(); - req.poll.expiresIn=pollDuration; - req.poll.multiple=pollAllowMultipleItem.isSelected(); - for(DraftPollOption opt:pollOptions) - req.poll.options.add(opt.edit.getText().toString()); - } - if(hasSpoiler && spoilerEdit.length()>0){ - req.spoilerText=spoilerEdit.getText().toString(); - } - if(quote != null){ - req.quoteId=quote.id; - } - if(uuid==null) - uuid=UUID.randomUUID().toString(); - + private void publish(){ sendingOverlay=new View(getActivity()); WindowManager.LayoutParams overlayParams=new WindowManager.LayoutParams(); overlayParams.type=WindowManager.LayoutParams.TYPE_APPLICATION_PANEL; @@ -1233,18 +1116,75 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr wm.addView(sendingOverlay, overlayParams); publishButton.setEnabled(false); - sendProgress.setVisibility(View.VISIBLE); - sendError.setVisibility(View.GONE); + V.setVisibilityAnimated(sendProgress, View.VISIBLE); - Callback resCallback = new Callback<>(){ + mediaViewController.saveAltTextsBeforePublishing(this::actuallyPublish, this::handlePublishError); + } + + private void actuallyPublish(){ + actuallyPublish(false); + } + private void actuallyPublish(boolean force){ + String text=mainEditText.getText().toString(); + CreateStatus.Request req=new CreateStatus.Request(); + if ("bottom".equals(postLang.encoding)) { + text = new StatusTextEncoder(Bottom::encode).encode(text); + req.spoilerText = "bottom-encoded emoji spam"; + } + if (localOnly && + AccountSessionManager.get(accountID).getLocalPreferences().glitchInstance && + !GLITCH_LOCAL_ONLY_PATTERN.matcher(text).matches()) { + text += " " + GLITCH_LOCAL_ONLY_SUFFIX; + } + req.status=text; + req.localOnly=localOnly; + req.visibility=localOnly && instance.isAkkoma() ? StatusPrivacy.LOCAL : statusVisibility; + req.sensitive=sensitive; + req.contentType=contentType==ContentType.UNSPECIFIED ? null : contentType; + req.scheduledAt=scheduledAt; + if(!mediaViewController.isEmpty()){ + req.mediaIds=mediaViewController.getAttachmentIDs(); + } + // 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) -> actuallyPublish(true)) + .setNegativeButton(R.string.publish, (d, w) -> { + updateScheduledAt(null); + actuallyPublish(); + }) + .show(); + return; + } + if(replyTo!=null || (editingStatus != null && editingStatus.inReplyToId!=null)){ + req.inReplyToId=editingStatus!=null ? editingStatus.inReplyToId : replyTo.id; + } + if(!pollViewController.isEmpty()){ + req.poll=pollViewController.getPollForRequest(); + } + if(hasSpoiler && spoilerEdit.length()>0){ + req.spoilerText=spoilerEdit.getText().toString(); + } + if(postLang!=null && postLang.language!=null){ + req.language=postLang.language.getLanguage(); + } + if(quote != null){ + req.quoteId=quote.id; + } + if(uuid==null) + uuid=UUID.randomUUID().toString(); + + Callback resCallback=new Callback<>(){ @Override public void onSuccess(Status result){ maybeDeleteScheduledPost(() -> { wm.removeView(sendingOverlay); sendingOverlay=null; - if(editingStatus==null){ + if(editingStatus==null || redraftStatus){ E.post(new StatusCreatedEvent(result, accountID)); - if(replyTo!=null){ + if(replyTo!=null && !redraftStatus){ replyTo.repliesCount++; E.post(new StatusCountersUpdatedEvent(replyTo)); } @@ -1276,7 +1216,7 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr @Override public void onError(ErrorResponse error){ - publishErrorCallback(error); + handlePublishError(error); } }; @@ -1304,7 +1244,7 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr @Override public void onError(ErrorResponse error) { - publishErrorCallback(error); + handlePublishError(error); } }).exec(accountID); }else{ @@ -1313,43 +1253,59 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr .setMessage(R.string.sk_scheduled_too_soon) .setPositiveButton(R.string.ok, (a, b)->{}) .show(); - publishErrorCallback(null); + handlePublishError(null); publishButton.setEnabled(false); } - if (replyTo == null) { - 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(); + if (replyTo == null) updateRecentLanguages(); + } + + private void handlePublishError(ErrorResponse error){ + wm.removeView(sendingOverlay); + sendingOverlay=null; + V.setVisibilityAnimated(sendProgress, View.GONE); + publishButton.setEnabled(true); + if(error instanceof MastodonErrorResponse me){ + new M3AlertDialogBuilder(getActivity()) + .setTitle(R.string.post_failed) + .setMessage(me.error) + .setPositiveButton(R.string.retry, (dlg, btn)->publish()) + .setNegativeButton(R.string.cancel, null) + .show(); + }else if(error!=null){ + error.showToast(getActivity()); } } + private void updateRecentLanguages() { + if (postLang == null || postLang.language == null) return; + String language = postLang.language.getLanguage(); + AccountLocalPreferences prefs = AccountSessionManager.get(accountID).getLocalPreferences(); + prefs.recentLanguages.remove(language); + prefs.recentLanguages.add(0, language); + if (postLang.encoding != null) { + prefs.recentLanguages.remove(postLang.encoding); + prefs.recentLanguages.add(0, postLang.encoding); + } + if ("bottom".equals(postLang.encoding) && !prefs.bottomEncoding) prefs.bottomEncoding = true; + prefs.save(); + } + private boolean hasDraft(){ if(getArguments().getBoolean("hasDraft", false)) return true; if(editingStatus!=null){ if(!mainEditText.getText().toString().equals(initialText)) return true; List existingMediaIDs=editingStatus.mediaAttachments.stream().map(a->a.id).collect(Collectors.toList()); - if(!existingMediaIDs.equals(attachments.stream().map(a->a.serverAttachment.id).collect(Collectors.toList()))) + if(!existingMediaIDs.equals(mediaViewController.getAttachmentIDs())) return true; if(!statusVisibility.equals(editingStatus.visibility)) return true; if(scheduledStatus != null && !scheduledStatus.scheduledAt.equals(scheduledAt)) return true; - return pollChanged; + if(sensitive != editingStatus.sensitive) return true; + return pollViewController.isPollChanged(); } - boolean pollFieldsHaveContent=false; - for(DraftPollOption opt:pollOptions) - pollFieldsHaveContent|=opt.edit.length()>0; - return (mainEditText.length()>0 && !mainEditText.getText().toString().equals(initialText)) || !attachments.isEmpty() || pollFieldsHaveContent; + boolean pollFieldsHaveContent=pollViewController.getNonEmptyOptionsCount()>0; + return (mainEditText.length()>0 && !mainEditText.getText().toString().equals(initialText)) || !mediaViewController.isEmpty() || pollFieldsHaveContent; } @Override @@ -1379,22 +1335,24 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr @Override public void onFragmentResult(int reqCode, boolean success, Bundle result){ if(reqCode==IMAGE_DESCRIPTION_RESULT && success){ - Attachment updated=Parcels.unwrap(result.getParcelable("attachment")); - for(DraftMediaAttachment att:attachments){ - if(att.serverAttachment.id.equals(updated.id)){ - att.serverAttachment=updated; - att.description=updated.description; - att.descriptionView.setText(att.description); - break; - } - } - } else if (reqCode == SCHEDULED_STATUS_OPENED_RESULT && success && getActivity() != null) { - Nav.finish(this); + String attID=result.getString("attachment"); + String text=result.getString("text"); + mediaViewController.setAltTextByID(attID, text); + }else if(reqCode==AUTOCOMPLETE_ACCOUNT_RESULT && success){ + Account acc=Parcels.unwrap(result.getParcelable("selectedAccount")); + if(currentAutocompleteSpan==null) + return; + Editable e=mainEditText.getText(); + int start=e.getSpanStart(currentAutocompleteSpan); + int end=e.getSpanEnd(currentAutocompleteSpan); + e.removeSpan(currentAutocompleteSpan); + e.replace(start, end, '@'+acc.acct+' '); + finishAutocomplete(); } } private void confirmDiscardDraftAndFinish(){ - boolean attachmentsPending = attachments.stream().anyMatch(att -> att.state != AttachmentUploadState.DONE); + boolean attachmentsPending = mediaViewController.areAnyAttachmentsNotDone(); if (attachmentsPending) new M3AlertDialogBuilder(getActivity()) .setTitle(R.string.sk_unfinished_attachments) .setMessage(R.string.sk_unfinished_attachments_message) @@ -1422,10 +1380,10 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr */ private void openFilePicker(boolean photoPicker){ Intent intent; - boolean usePhotoPicker=photoPicker && isPhotoPickerAvailable(); + boolean usePhotoPicker=photoPicker && UiUtils.isPhotoPickerAvailable(); if(usePhotoPicker){ intent=new Intent(MediaStore.ACTION_PICK_IMAGES); - intent.putExtra(MediaStore.EXTRA_PICK_IMAGES_MAX, MAX_ATTACHMENTS-getMediaAttachmentsCount()); + intent.putExtra(MediaStore.EXTRA_PICK_IMAGES_MAX, mediaViewController.getMaxAttachments()-mediaViewController.getMediaAttachmentsCount()); }else{ intent=new Intent(Intent.ACTION_GET_CONTENT); intent.addCategory(Intent.CATEGORY_OPENABLE); @@ -1453,11 +1411,11 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr if(requestCode==MEDIA_RESULT && resultCode==Activity.RESULT_OK){ Uri single=data.getData(); if(single!=null){ - addMediaAttachment(single, null); + mediaViewController.addMediaAttachment(single, null); }else{ ClipData clipData=data.getClipData(); for(int i=0;isizeLimit){ - float mb=sizeLimit/(float) (1024*1024); - String sMb=String.format(Locale.getDefault(), mb%1f==0f ? "%.0f" : "%.2f", mb); - showMediaAttachmentError(getString(R.string.media_attachment_too_big, UiUtils.getFileName(uri), sMb)); - return false; - } - } - } - pollBtn.setEnabled(false); - DraftMediaAttachment draft=new DraftMediaAttachment(); - draft.uri=uri; - draft.mimeType=type; - draft.description=description; - attachmentsView.addView(createMediaAttachmentView(draft)); - attachments.add(draft); - attachmentsView.setVisibility(View.VISIBLE); - draft.setOverlayVisible(true, false); - - if(!areThereAnyUploadingAttachments()){ - uploadNextQueuedAttachment(); - } - updatePublishButtonState(); - updateSensitive(); - if(getMediaAttachmentsCount()==MAX_ATTACHMENTS) - mediaBtn.setEnabled(false); - return true; - } - - private void showMediaAttachmentError(String text){ - if(!attachmentsErrorShowing){ - Toast.makeText(getActivity(), text, Toast.LENGTH_SHORT).show(); - attachmentsErrorShowing=true; - contentView.postDelayed(()->attachmentsErrorShowing=false, 2000); - } - } - - private View createMediaAttachmentView(DraftMediaAttachment draft){ - View thumb=getActivity().getLayoutInflater().inflate(R.layout.compose_media_thumb, attachmentsView, false); - ImageView img=thumb.findViewById(R.id.thumb); - if(draft.serverAttachment!=null){ - if(draft.serverAttachment.previewUrl!=null) - ViewImageLoader.load(img, draft.serverAttachment.blurhashPlaceholder, new UrlImageLoaderRequest(draft.serverAttachment.previewUrl, V.dp(250), V.dp(250))); - }else{ - if(draft.mimeType.startsWith("image/")){ - ViewImageLoader.load(img, null, new UrlImageLoaderRequest(draft.uri, V.dp(250), V.dp(250))); - }else if(draft.mimeType.startsWith("video/")){ - loadVideoThumbIntoView(img, draft.uri); - } - } - TextView fileName=thumb.findViewById(R.id.file_name); - fileName.setText(UiUtils.getFileName(draft.serverAttachment!=null ? Uri.parse(draft.serverAttachment.url) : draft.uri)); - - draft.view=thumb; - draft.imageView=img; - draft.progressBar=thumb.findViewById(R.id.progress); - draft.infoBar=thumb.findViewById(R.id.info_bar); - draft.overlay=thumb.findViewById(R.id.overlay); - draft.descriptionView=thumb.findViewById(R.id.description); - draft.uploadStateTitle=thumb.findViewById(R.id.state_title); - draft.uploadStateText=thumb.findViewById(R.id.state_text); - ImageButton btn=thumb.findViewById(R.id.remove_btn); - btn.setTag(draft); - btn.setOnClickListener(this::onRemoveMediaAttachmentClick); - btn=thumb.findViewById(R.id.remove_btn2); - btn.setTag(draft); - btn.setOnClickListener(this::onRemoveMediaAttachmentClick); - ImageButton retry=thumb.findViewById(R.id.retry_or_cancel_upload); - retry.setTag(draft); - retry.setOnClickListener(this::onRetryOrCancelMediaUploadClick); - draft.retryButton=retry; - draft.infoBar.setTag(draft); - draft.infoBar.setOnClickListener(this::onEditMediaDescriptionClick); - - if(!TextUtils.isEmpty(draft.description)) - draft.descriptionView.setText(draft.description); - - if(Build.VERSION.SDK_INT>=Build.VERSION_CODES.S){ - draft.overlay.setBackgroundColor(0xA6000000); - } - - if(draft.state==AttachmentUploadState.UPLOADING || draft.state==AttachmentUploadState.PROCESSING || draft.state==AttachmentUploadState.QUEUED){ - draft.progressBar.setVisibility(View.GONE); - }else if(draft.state==AttachmentUploadState.ERROR){ - draft.setOverlayVisible(true, false); - } - - return thumb; - } - - public void addFakeMediaAttachment(Uri uri, String description){ - pollBtn.setEnabled(false); - DraftMediaAttachment draft=new DraftMediaAttachment(); - draft.uri=uri; - draft.description=description; - attachmentsView.addView(createMediaAttachmentView(draft)); - attachments.add(draft); - attachmentsView.setVisibility(View.VISIBLE); - } - - private void uploadMediaAttachment(DraftMediaAttachment attachment){ - if(areThereAnyUploadingAttachments()){ - throw new IllegalStateException("there is already an attachment being uploaded"); - } - attachment.state=AttachmentUploadState.UPLOADING; - attachment.progressBar.setVisibility(View.VISIBLE); - ObjectAnimator rotationAnimator=ObjectAnimator.ofFloat(attachment.progressBar, View.ROTATION, 0f, 360f); - rotationAnimator.setInterpolator(new LinearInterpolator()); - rotationAnimator.setDuration(1500); - rotationAnimator.setRepeatCount(ObjectAnimator.INFINITE); - rotationAnimator.start(); - attachment.progressBarAnimator=rotationAnimator; - int maxSize=0; - String contentType=getActivity().getContentResolver().getType(attachment.uri); - if(contentType!=null && contentType.startsWith("image/")){ - maxSize=2_073_600; // TODO get this from instance configuration when it gets added there - } - attachment.uploadStateTitle.setText(""); - attachment.uploadStateText.setText(""); - attachment.progressBar.setProgress(0); - attachment.speedTracker.reset(); - attachment.speedTracker.addSample(0); - attachment.uploadRequest=(UploadAttachment) new UploadAttachment(attachment.uri, maxSize, attachment.description) - .setProgressListener(new ProgressListener(){ - @Override - public void onProgress(long transferred, long total){ - if(updateUploadEtaRunnable==null){ - // getting a NoSuchMethodError: No static method -$$Nest$mupdateUploadETAs(ComposeFragment;)V in class ComposeFragment - // when using method reference out of nowhere after changing code elsewhere. no idea. programming is awful, actually - // noinspection Convert2MethodRef - UiUtils.runOnUiThread(updateUploadEtaRunnable=()->ComposeFragment.this.updateUploadETAs(), 50); - } - int progress=Math.round(transferred/(float)total*attachment.progressBar.getMax()); - if(Build.VERSION.SDK_INT>=24) - attachment.progressBar.setProgress(progress, true); - else - attachment.progressBar.setProgress(progress); - - attachment.speedTracker.setTotalBytes(total); - attachment.uploadStateTitle.setText(getString(R.string.file_upload_progress, UiUtils.formatFileSize(getActivity(), transferred, true), UiUtils.formatFileSize(getActivity(), total, true))); - attachment.speedTracker.addSample(transferred); - } - }) - .setCallback(new Callback<>(){ - @Override - public void onSuccess(Attachment result){ - attachment.serverAttachment=result; - if(TextUtils.isEmpty(result.url)){ - attachment.state=AttachmentUploadState.PROCESSING; - attachment.processingPollingRunnable=()->pollForMediaAttachmentProcessing(attachment); - if(getActivity()==null) - return; - attachment.uploadStateTitle.setText(R.string.upload_processing); - attachment.uploadStateText.setText(""); - UiUtils.runOnUiThread(attachment.processingPollingRunnable, 1000); - if(!areThereAnyUploadingAttachments()) - uploadNextQueuedAttachment(); - }else{ - finishMediaAttachmentUpload(attachment); - } - } - - @Override - public void onError(ErrorResponse error){ - attachment.uploadRequest=null; - attachment.progressBarAnimator=null; - attachment.state=AttachmentUploadState.ERROR; - attachment.uploadStateTitle.setText(R.string.upload_failed); - if(error instanceof MastodonErrorResponse er){ - if(er.underlyingException instanceof SocketException || er.underlyingException instanceof UnknownHostException || er.underlyingException instanceof InterruptedIOException) - attachment.uploadStateText.setText(R.string.upload_error_connection_lost); - else - attachment.uploadStateText.setText(er.error); - }else{ - attachment.uploadStateText.setText(""); - } - attachment.retryButton.setImageResource(R.drawable.ic_fluent_arrow_clockwise_24_filled); - attachment.retryButton.setContentDescription(getString(R.string.retry_upload)); - - rotationAnimator.cancel(); - V.setVisibilityAnimated(attachment.retryButton, View.VISIBLE); - V.setVisibilityAnimated(attachment.progressBar, View.GONE); - - if(!areThereAnyUploadingAttachments()) - uploadNextQueuedAttachment(); - } - }) - .exec(accountID); - } - - private void onRemoveMediaAttachmentClick(View v){ - DraftMediaAttachment att=(DraftMediaAttachment) v.getTag(); - if(att.isUploadingOrProcessing()) - att.cancelUpload(); - attachments.remove(att); - if(!areThereAnyUploadingAttachments()) - uploadNextQueuedAttachment(); - attachmentsView.removeView(att.view); - if(getMediaAttachmentsCount()==0) - attachmentsView.setVisibility(View.GONE); - updatePublishButtonState(); - pollBtn.setEnabled(attachments.isEmpty()); - mediaBtn.setEnabled(true); - updateSensitive(); - } - - private void onRetryOrCancelMediaUploadClick(View v){ - DraftMediaAttachment att=(DraftMediaAttachment) v.getTag(); - if(att.state==AttachmentUploadState.ERROR){ - att.retryButton.setImageResource(R.drawable.ic_fluent_dismiss_24_filled); - att.retryButton.setContentDescription(getString(R.string.cancel)); - V.setVisibilityAnimated(att.progressBar, View.VISIBLE); - att.state=AttachmentUploadState.QUEUED; - if(!areThereAnyUploadingAttachments()){ - uploadNextQueuedAttachment(); - } - }else{ - onRemoveMediaAttachmentClick(v); - } - } - - private void pollForMediaAttachmentProcessing(DraftMediaAttachment attachment){ - attachment.processingPollingRequest=(GetAttachmentByID) new GetAttachmentByID(attachment.serverAttachment.id) - .setCallback(new Callback<>(){ - @Override - public void onSuccess(Attachment result){ - attachment.processingPollingRequest=null; - if(!TextUtils.isEmpty(result.url)){ - attachment.processingPollingRunnable=null; - attachment.serverAttachment=result; - finishMediaAttachmentUpload(attachment); - }else if(getActivity()!=null){ - UiUtils.runOnUiThread(attachment.processingPollingRunnable, 1000); - } - } - - @Override - public void onError(ErrorResponse error){ - attachment.processingPollingRequest=null; - if(getActivity()!=null) - UiUtils.runOnUiThread(attachment.processingPollingRunnable, 1000); - } - }) - .exec(accountID); - } - - private void finishMediaAttachmentUpload(DraftMediaAttachment attachment){ - if(attachment.state!=AttachmentUploadState.PROCESSING && attachment.state!=AttachmentUploadState.UPLOADING) - throw new IllegalStateException("Unexpected state "+attachment.state); - attachment.uploadRequest=null; - attachment.state=AttachmentUploadState.DONE; - attachment.progressBar.setVisibility(View.GONE); - if(!areThereAnyUploadingAttachments()) - uploadNextQueuedAttachment(); - updatePublishButtonState(); - - if(attachment.progressBarAnimator!=null){ - attachment.progressBarAnimator.cancel(); - attachment.progressBarAnimator=null; - } - attachment.setOverlayVisible(false, true); - } - - private void uploadNextQueuedAttachment(){ - for(DraftMediaAttachment att:attachments){ - if(att.state==AttachmentUploadState.QUEUED){ - uploadMediaAttachment(att); - return; - } - } - } - - private boolean areThereAnyUploadingAttachments(){ - for(DraftMediaAttachment att:attachments){ - if(att.state==AttachmentUploadState.UPLOADING) - return true; - } - return false; - } - - private void updateUploadETAs(){ - if(!areThereAnyUploadingAttachments()){ - UiUtils.removeCallbacks(updateUploadEtaRunnable); - updateUploadEtaRunnable=null; - return; - } - for(DraftMediaAttachment att:attachments){ - if(att.state==AttachmentUploadState.UPLOADING){ - long eta=att.speedTracker.updateAndGetETA(); -// Log.i(TAG, "onProgress: transfer speed "+UiUtils.formatFileSize(getActivity(), Math.round(att.speedTracker.getLastSpeed()), false)+" average "+UiUtils.formatFileSize(getActivity(), Math.round(att.speedTracker.getAverageSpeed()), false)+" eta "+eta); - String time=String.format("%d:%02d", eta/60, eta%60); - att.uploadStateText.setText(getString(R.string.file_upload_time_remaining, time)); - } - } - UiUtils.runOnUiThread(updateUploadEtaRunnable, 50); - } - - private void onEditMediaDescriptionClick(View v){ - 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); - args.putParcelable("uri", att.uri); - args.putString("existingDescription", att.description); - Nav.goForResult(getActivity(), ComposeImageDescriptionFragment.class, args, IMAGE_DESCRIPTION_RESULT, this); + public void updateMediaPollStates(){ + pollBtn.setSelected(pollViewController.isShown()); + mediaBtn.setEnabled(!pollViewController.isShown() && mediaViewController.canAddMoreAttachments()); + pollBtn.setEnabled(mediaViewController.isEmpty()); } private void togglePoll(){ - if(pollOptions.isEmpty()){ - pollBtn.setSelected(true); - mediaBtn.setEnabled(false); - pollWrap.setVisibility(View.VISIBLE); - for(int i=0;i<2;i++) - createDraftPollOption(); - updatePollOptionHints(); - }else{ - pollBtn.setSelected(false); - mediaBtn.setEnabled(true); - pollWrap.setVisibility(View.GONE); - addPollOptionBtn.setVisibility(View.VISIBLE); - pollOptionsView.removeAllViews(); - pollOptions.clear(); - pollDuration=24*3600; - } + pollViewController.toggle(); updatePublishButtonState(); - } - - private DraftPollOption createDraftPollOption(){ - DraftPollOption option=new DraftPollOption(); - option.view=LayoutInflater.from(getActivity()).inflate(R.layout.compose_poll_option, pollOptionsView, false); - option.edit=option.view.findViewById(R.id.edit); - option.dragger=option.view.findViewById(R.id.dragger_thingy); - ImageView icon = option.view.findViewById(R.id.icon); - icon.setImageDrawable(getContext().getDrawable(pollAllowMultipleItem.isSelected() ? - R.drawable.ic_poll_checkbox_regular_selector : - R.drawable.ic_poll_option_button - )); - - option.dragger.setOnLongClickListener(v->{ - pollOptionsView.startDragging(option.view); - return true; - }); - option.edit.addTextChangedListener(new SimpleTextWatcher(e->{ - if(!creatingView) - pollChanged=true; - updatePublishButtonState(); - })); - - int maxCharactersPerOption = 50; - if(instance.configuration!=null && instance.configuration.polls!=null && instance.configuration.polls.maxCharactersPerOption>0) - maxCharactersPerOption = instance.configuration.polls.maxCharactersPerOption; - else if(instance.pollLimits!=null && instance.pollLimits.maxOptionChars>0) - maxCharactersPerOption = instance.pollLimits.maxOptionChars; - option.edit.setFilters(new InputFilter[]{new InputFilter.LengthFilter(maxCharactersPerOption)}); - - pollOptionsView.addView(option.view); - pollOptions.add(option); - - int maxPollOptions = 4; - if(instance.configuration!=null && instance.configuration.polls!=null && instance.configuration.polls.maxOptions>0) - maxPollOptions = instance.configuration.polls.maxOptions; - else if (instance.pollLimits!=null && instance.pollLimits.maxOptions>0) - maxPollOptions = instance.pollLimits.maxOptions; - - if(pollOptions.size()==maxPollOptions) - addPollOptionBtn.setVisibility(View.GONE); - return option; - } - - private void updatePollOptionHints(){ - int i=0; - for(DraftPollOption option:pollOptions){ - option.edit.setHint(getString(R.string.poll_option_hint, ++i)); - } - } - - private void onSwapPollOptions(int oldIndex, int newIndex){ - pollOptions.add(newIndex, pollOptions.remove(oldIndex)); - updatePollOptionHints(); - pollChanged=true; - } - - private void showPollDurationMenu(){ - PopupMenu menu=new PopupMenu(getActivity(), pollDurationView); - menu.getMenu().add(0, 1, 0, getResources().getQuantityString(R.plurals.x_minutes, 5, 5)); - 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_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 -> 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())); - pollChanged=true; - return true; - }); - menu.show(); + updateMediaPollStates(); } private void toggleSpoiler(){ hasSpoiler=!hasSpoiler; if(hasSpoiler){ - spoilerEdit.setVisibility(View.VISIBLE); + spoilerWrap.setVisibility(View.VISIBLE); spoilerBtn.setSelected(true); spoilerEdit.requestFocus(); }else{ - spoilerEdit.setVisibility(View.GONE); + spoilerWrap.setVisibility(View.GONE); spoilerEdit.setText(""); spoilerBtn.setSelected(false); mainEditText.requestFocus(); updateCharCounter(); - sensitiveIcon.setVisibility(getMediaAttachmentsCount() > 0 ? View.VISIBLE : View.GONE); + sensitiveBtn.setVisibility(mediaViewController.getMediaAttachmentsCount() > 0 ? View.VISIBLE : View.GONE); } updateSensitive(); } private void toggleSensitive() { sensitive=!sensitive; - sensitiveIcon.setSelected(sensitive); + sensitiveBtn.setSelected(sensitive); } - private void updateSensitive() { - sensitiveItem.setVisibility(View.GONE); - if (!attachments.isEmpty() && !hasSpoiler) sensitiveItem.setVisibility(View.VISIBLE); - if (attachments.isEmpty()) sensitive = false; + public void updateSensitive() { + sensitiveBtn.setVisibility(View.GONE); + if (!mediaViewController.isEmpty() && !hasSpoiler) sensitiveBtn.setVisibility(View.VISIBLE); + if (mediaViewController.isEmpty()) sensitive = false; } private void pickScheduledDateTime() { @@ -1978,7 +1511,7 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr private void updateScheduledAt(Instant scheduledAt) { this.scheduledAt = scheduledAt; updatePublishButtonState(); - scheduleDraftView.setVisibility(scheduledAt == null ? View.GONE : View.VISIBLE); + V.setVisibilityAnimated(scheduleDraftView, scheduledAt == null ? View.GONE : View.VISIBLE); draftMenuItem.setVisible(true); scheduleMenuItem.setVisible(true); undraftMenuItem.setVisible(false); @@ -1991,6 +1524,9 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr scheduleTimeBtn.setVisibility(View.GONE); scheduleDraftText.setText(R.string.sk_compose_draft); scheduleDraftText.setCompoundDrawablesWithIntrinsicBounds(R.drawable.ic_fluent_drafts_20_regular, 0, 0, 0); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + scheduleDraftDismiss.setTooltipText(getString(R.string.sk_compose_no_draft)); + } scheduleDraftDismiss.setContentDescription(getString(R.string.sk_compose_no_draft)); draftsBtn.setCompoundDrawablesWithIntrinsicBounds(GlobalUserPreferences.relocatePublishButton ? R.drawable.ic_fluent_drafts_24_regular : R.drawable.ic_fluent_drafts_20_filled, 0, 0, 0); @@ -2009,6 +1545,9 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr scheduleTimeBtn.setText(at); scheduleDraftText.setText(R.string.sk_compose_scheduled); scheduleDraftText.setCompoundDrawablesWithIntrinsicBounds(0, 0, 0, 0); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + scheduleDraftDismiss.setTooltipText(getString(R.string.sk_compose_no_schedule)); + } scheduleDraftDismiss.setContentDescription(getString(R.string.sk_compose_no_schedule)); draftsBtn.setCompoundDrawablesWithIntrinsicBounds(GlobalUserPreferences.relocatePublishButton ? R.drawable.ic_fluent_clock_24_filled : R.drawable.ic_fluent_clock_20_filled, 0, 0, 0); if(GlobalUserPreferences.relocatePublishButton) @@ -2029,40 +1568,37 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr } } - private int getMediaAttachmentsCount(){ - return attachments.size(); - } - private void updateHeaders() { - UiUtils.setExtraTextInfo(getContext(), selfExtraText, statusVisibility, localOnly); - if (replyTo != null) UiUtils.setExtraTextInfo(getContext(), extraText, replyTo.visibility, replyTo.localOnly); + UiUtils.setExtraTextInfo(getContext(), selfExtraText, null, false, false, localOnly || statusVisibility==StatusPrivacy.LOCAL, null); + if (replyTo != null) UiUtils.setExtraTextInfo(getContext(), extraText, pronouns, true, false, replyTo.localOnly || replyTo.visibility==StatusPrivacy.LOCAL, replyTo.account); } private void buildVisibilityPopup(View v){ visibilityPopup=new PopupMenu(getActivity(), v); visibilityPopup.inflate(R.menu.compose_visibility); Menu m=visibilityPopup.getMenu(); - if (isInstancePixelfed()) { + if(isInstancePixelfed()){ m.findItem(R.id.vis_private).setVisible(false); } - MenuItem localOnlyItem = visibilityPopup.getMenu().findItem(R.id.local_only); - boolean prefsSaysSupported = GlobalUserPreferences.accountsWithLocalOnlySupport.contains(accountID); - if (isInstanceAkkoma()) { + MenuItem localOnlyItem=visibilityPopup.getMenu().findItem(R.id.local_only); + AccountLocalPreferences prefs=AccountSessionManager.get(accountID).getLocalPreferences(); + boolean prefsSaysSupported=prefs.localOnlySupported; + if(isInstanceAkkoma()){ m.findItem(R.id.vis_local).setVisible(true); - } else if (localOnly || prefsSaysSupported) { + }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); + Status status=editingStatus!=null ? editingStatus : replyTo; + if(!prefsSaysSupported){ + prefs.localOnlySupported=true; + if(GLITCH_LOCAL_ONLY_PATTERN.matcher(status.getStrippedText()).matches()){ + prefs.glitchInstance=true; } - GlobalUserPreferences.save(); + prefs.save(); } } UiUtils.enablePopupMenuIcons(getActivity(), visibilityPopup); - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) m.setGroupDividerEnabled(true); + if(Build.VERSION.SDK_INT>=Build.VERSION_CODES.P) m.setGroupDividerEnabled(true); visibilityPopup.setOnMenuItemClickListener(new PopupMenu.OnMenuItemClickListener(){ @Override public boolean onMenuItemClick(MenuItem item){ @@ -2078,10 +1614,10 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr }else if(id==R.id.vis_local){ statusVisibility=StatusPrivacy.LOCAL; } - if (id == R.id.local_only) { - localOnly = !item.isChecked(); + if(id==R.id.local_only){ + localOnly=!item.isChecked(); item.setChecked(localOnly); - } else { + }else{ item.setChecked(true); } updateVisibilityIcon(); @@ -2094,37 +1630,56 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr @SuppressLint("ClickableViewAccessibility") private void buildContentTypePopup(View btn) { contentTypePopup=new PopupMenu(getActivity(), btn); - contentTypePopup.inflate(R.menu.compose_content_type); Menu m = contentTypePopup.getMenu(); - ContentType.adaptMenuToInstance(m, instance); - if (contentType != null) m.findItem(R.id.content_type_null).setVisible(false); + for(ContentType value : ContentType.values()){ + if(!value.supportedByInstance(instance)) continue; + m.add(0, value.ordinal(), Menu.NONE, value.getName()); + } + m.setGroupCheckable(0, true, true); + if (contentType!=ContentType.UNSPECIFIED || editingStatus!=null){ + // setting content type to null while editing will just leave it unchanged + m.findItem(ContentType.UNSPECIFIED.ordinal()).setVisible(false); + } contentTypePopup.setOnMenuItemClickListener(i->{ - int id=i.getItemId(); - if (id == R.id.content_type_null) contentType = null; - else if (id == R.id.content_type_plain) contentType = ContentType.PLAIN; - else if (id == R.id.content_type_html) contentType = ContentType.HTML; - else if (id == R.id.content_type_markdown) contentType = ContentType.MARKDOWN; - else if (id == R.id.content_type_bbcode) contentType = ContentType.BBCODE; - else if (id == R.id.content_type_misskey_markdown) contentType = ContentType.MISSKEY_MARKDOWN; - else return false; - btn.setSelected(id != R.id.content_type_null && id != R.id.content_type_plain); + int index=i.getItemId(); + contentType=ContentType.values()[index]; + btn.setSelected(index!=ContentType.UNSPECIFIED.ordinal() && index!=ContentType.PLAIN.ordinal()); i.setChecked(true); return true; }); - if (!GlobalUserPreferences.accountsWithContentTypesEnabled.contains(accountID)) { + if (!AccountSessionManager.get(accountID).getLocalPreferences().contentTypesEnabled) { btn.setVisibility(View.GONE); } } + private void onVisibilityClick(View v){ + PopupMenu menu=new PopupMenu(getActivity(), v); + menu.inflate(R.menu.compose_visibility); + menu.setOnMenuItemClickListener(item->{ + int id=item.getItemId(); + if(id==R.id.vis_public){ + statusVisibility=StatusPrivacy.PUBLIC; + }else if(id==R.id.vis_followers){ + statusVisibility=StatusPrivacy.PRIVATE; + }else if(id==R.id.vis_private){ + statusVisibility=StatusPrivacy.DIRECT; + } + item.setChecked(true); + updateVisibilityIcon(); + return true; + }); + menu.show(); + } + private void loadDefaultStatusVisibility(Bundle savedInstanceState) { if(replyTo != null) { statusVisibility = (replyTo.visibility == StatusPrivacy.PUBLIC && GlobalUserPreferences.defaultToUnlistedReplies ? StatusPrivacy.UNLISTED : replyTo.visibility); } AccountSessionManager asm = AccountSessionManager.getInstance(); - Preferences prefs = asm.getAccount(accountID).preferences; + Preferences prefs=asm.getAccount(accountID).preferences; if (prefs != null) { // Only override the reply visibility if our preference is more private // (and we're not replying to ourselves, or not at all) @@ -2133,45 +1688,31 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr statusVisibility = prefs.postingDefaultVisibility; } } - - // A saved privacy setting from a previous compose session wins over all - if(savedInstanceState !=null){ - statusVisibility = (StatusPrivacy) savedInstanceState.getSerializable("visibility"); - } } private void updateVisibilityIcon(){ + if(getActivity()==null) + return; if(statusVisibility==null){ // TODO find out why this happens statusVisibility=StatusPrivacy.PUBLIC; } - visibilityBtn.setImageResource(switch(statusVisibility){ - case PUBLIC -> R.drawable.ic_fluent_earth_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; + visibilityBtn.setText(switch(statusVisibility){ + case PUBLIC -> R.string.visibility_public; + 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; }); - } - - private void togglePollAllowMultiple() { - updatePollAllowMultiple(!pollAllowMultipleItem.isSelected()); - } - - private void updatePollAllowMultiple(boolean multiple){ - pollAllowMultipleItem.setSelected(multiple); - pollAllowMultipleCheckbox.setChecked(multiple); - ImageView btn = addPollOptionBtn.findViewById(R.id.add_poll_option_icon); - btn.setImageDrawable(getContext().getDrawable(multiple ? - R.drawable.ic_fluent_add_square_24_regular : - R.drawable.ic_fluent_add_circle_24_regular - )); - for (DraftPollOption opt:pollOptions) { - ImageView icon = opt.view.findViewById(R.id.icon); - icon.setImageDrawable(getContext().getDrawable(multiple ? - R.drawable.ic_poll_checkbox_regular_selector : - R.drawable.ic_poll_option_button - )); - } + Drawable icon=getResources().getDrawable(switch(statusVisibility){ + case PUBLIC -> R.drawable.ic_fluent_earth_16_regular; + case UNLISTED -> R.drawable.ic_fluent_lock_open_16_regular; + case PRIVATE -> R.drawable.ic_fluent_lock_closed_16_filled; + case DIRECT -> R.drawable.ic_fluent_mention_16_regular; + case LOCAL -> R.drawable.ic_fluent_eye_16_regular; + }, getActivity().getTheme()).mutate(); + icon.setBounds(0, 0, V.dp(18), V.dp(18)); + visibilityBtn.setCompoundDrawableTintList(getContext().getResources().getColorStateList(R.color.m3_primary_selector, getContext().getTheme())); + visibilityBtn.setCompoundDrawablesRelative(icon, null, visibilityBtn.getCompoundDrawablesRelative()[2], null); } @Override @@ -2190,18 +1731,6 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr String spanText=e.toString().substring(e.getSpanStart(span), e.getSpanEnd(span)); autocompleteViewController.setText(spanText); } - - View autocompleteView=autocompleteViewController.getView(); - Layout layout=mainEditText.getLayout(); - int line=layout.getLineForOffset(start); - int offsetY=layout.getLineBottom(line); - FrameLayout.LayoutParams lp=(FrameLayout.LayoutParams) autocompleteView.getLayoutParams(); - if(lp.topMargin!=offsetY){ - lp.topMargin=offsetY; - mainEditTextWrap.requestLayout(); - } - int offsetX=Math.round(layout.getPrimaryHorizontal(start))+mainEditText.getPaddingLeft(); - autocompleteViewController.setArrowOffset(offsetX); }else if(currentAutocompleteSpan!=null){ finishAutocomplete(); } @@ -2219,7 +1748,7 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr @Override public boolean onAddMediaAttachmentFromEditText(Uri uri, String description){ - return addMediaAttachment(uri, description); + return mediaViewController.addMediaAttachment(uri, description); } private void startAutocomplete(ComposeAutocompleteSpan span){ @@ -2227,8 +1756,7 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr Editable e=mainEditText.getText(); String spanText=e.toString().substring(e.getSpanStart(span), e.getSpanEnd(span)); autocompleteViewController.setText(spanText); - View autocompleteView=autocompleteViewController.getView(); - autocompleteView.setVisibility(View.VISIBLE); + showAutocomplete(); } private void finishAutocomplete(){ @@ -2236,7 +1764,26 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr return; autocompleteViewController.setText(null); currentAutocompleteSpan=null; - autocompleteViewController.getView().setVisibility(View.GONE); + hideAutocomplete(); + } + + private void showAutocomplete(){ + UiUtils.beginLayoutTransition(bottomBar); + UiUtils.beginLayoutTransition(scheduleDraftView); + View autocompleteView=autocompleteViewController.getView(); + bottomBar.getLayoutParams().height=ViewGroup.LayoutParams.WRAP_CONTENT; + bottomBar.requestLayout(); + autocompleteView.setVisibility(View.VISIBLE); + autocompleteDivider.setVisibility(View.VISIBLE); + } + + private void hideAutocomplete(){ + UiUtils.beginLayoutTransition(bottomBar); + UiUtils.beginLayoutTransition(scheduleDraftView); + bottomBar.getLayoutParams().height=V.dp(56); + bottomBar.requestLayout(); + autocompleteViewController.getView().setVisibility(View.INVISIBLE); + autocompleteDivider.setVisibility(View.INVISIBLE); } private void onAutocompleteOptionSelected(String text){ @@ -2244,32 +1791,10 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr int start=e.getSpanStart(currentAutocompleteSpan); int end=e.getSpanEnd(currentAutocompleteSpan); e.replace(start, end, text+" "); - mainEditText.setSelection(start+text.length()+1); finishAutocomplete(); - } - - private void loadVideoThumbIntoView(ImageView target, Uri uri){ - MastodonAPIController.runInBackground(()->{ - Context context=getActivity(); - if(context==null) - return; - try{ - MediaMetadataRetriever mmr=new MediaMetadataRetriever(); - mmr.setDataSource(context, uri); - Bitmap frame=mmr.getFrameAtTime(3_000_000); - mmr.release(); - int size=Math.max(frame.getWidth(), frame.getHeight()); - int maxSize=V.dp(250); - if(size>maxSize){ - float factor=maxSize/(float)size; - frame=Bitmap.createScaledBitmap(frame, Math.round(frame.getWidth()*factor), Math.round(frame.getHeight()*factor), true); - } - Bitmap finalFrame=frame; - target.post(()->target.setImageBitmap(finalFrame)); - }catch(Exception x){ - Log.w(TAG, "loadVideoThumbIntoView: error getting video frame", x); - } - }); + InputConnection conn=mainEditText.getCurrentInputConnection(); + if(conn!=null) + conn.finishComposingText(); } @Override @@ -2287,85 +1812,50 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr return !UiUtils.isDarkTheme(); } - @Parcel - static class DraftMediaAttachment{ - public Attachment serverAttachment; - public Uri uri; - public transient UploadAttachment uploadRequest; - public transient GetAttachmentByID processingPollingRequest; - public String description; - public String mimeType; - public AttachmentUploadState state=AttachmentUploadState.QUEUED; - - public transient View view; - public transient ProgressBar progressBar; - public transient TextView descriptionView; - public transient View overlay; - public transient View infoBar; - public transient ImageButton retryButton; - public transient ObjectAnimator progressBarAnimator; - public transient Runnable processingPollingRunnable; - public transient ImageView imageView; - public transient TextView uploadStateTitle, uploadStateText; - public transient TransferSpeedTracker speedTracker=new TransferSpeedTracker(); - - public void cancelUpload(){ - switch(state){ - case UPLOADING -> { - if(uploadRequest!=null){ - uploadRequest.cancel(); - uploadRequest=null; - } - } - case PROCESSING -> { - if(processingPollingRunnable!=null){ - UiUtils.removeCallbacks(processingPollingRunnable); - processingPollingRunnable=null; - } - if(processingPollingRequest!=null){ - processingPollingRequest.cancel(); - processingPollingRequest=null; - } - } - default -> throw new IllegalStateException("Unexpected state "+state); - } - } - - public boolean isUploadingOrProcessing(){ - return state==AttachmentUploadState.UPLOADING || state==AttachmentUploadState.PROCESSING; - } - - public void setOverlayVisible(boolean visible, boolean animated){ - if(Build.VERSION.SDK_INT>=Build.VERSION_CODES.S){ - if(visible){ - imageView.setRenderEffect(RenderEffect.createBlurEffect(V.dp(16), V.dp(16), Shader.TileMode.REPEAT)); - }else{ - imageView.setRenderEffect(null); - } - } - int infoBarVis=visible ? View.GONE : View.VISIBLE; - int overlayVis=visible ? View.VISIBLE : View.GONE; - if(animated){ - V.setVisibilityAnimated(infoBar, infoBarVis); - V.setVisibilityAnimated(overlay, overlayVis); - }else{ - infoBar.setVisibility(infoBarVis); - overlay.setVisibility(overlayVis); - } - } + public boolean getWasDetached(){ + return wasDetached; } - enum AttachmentUploadState{ - QUEUED, - UPLOADING, - PROCESSING, - ERROR, - DONE + public boolean isCreatingView(){ + return creatingView; } - private static class DraftPollOption{ - public EditText edit; - public View view; - public View dragger; + @Override + public String getAccountID(){ + return accountID; + } + + public void addFakeMediaAttachment(Uri uri, String description){ + mediaViewController.addFakeMediaAttachment(uri, description); + } + + private void showLanguageAlert(){ + AccountSession session=AccountSessionManager.get(accountID); + ComposeLanguageAlertViewController vc=new ComposeLanguageAlertViewController(getActivity(), session.preferences!=null ? session.preferences.postingDefaultLanguage : null, postLang, mainEditText.getText().toString(), languageResolver, session); + new M3AlertDialogBuilder(getActivity()) + .setTitle(R.string.language) + .setView(vc.getView()) + .setPositiveButton(R.string.ok, (dialog, which)->setPostLanguage(vc.getSelectedOption())) + .setNegativeButton(R.string.cancel, null) + .show(); + } + + private void setPostLanguage(String lang) { + setPostLanguage(lang == null ? languageResolver.getDefault() : languageResolver.fromOrFallback(lang)); + } + + private void setPostLanguage(MastodonLanguage lang) { + setPostLanguage(new ComposeLanguageAlertViewController.SelectedOption(lang)); + } + + private void setPostLanguage(ComposeLanguageAlertViewController.SelectedOption opt){ + postLang=opt; + if (Objects.equals("bottom", opt.encoding)) { + languageButton.setText("\uD83E\uDD7A\uD83D\uDC49\uD83D\uDC48"); + languageButton.setContentDescription(opt.encoding); + return; + } + languageButton.setText(opt.language.getLanguageName()); + languageButton.setContentDescription(getActivity().getString(R.string.sk_post_language, opt.language.getDefaultName())); } } diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/ComposeImageDescriptionFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/ComposeImageDescriptionFragment.java index 02bea5f6d..5e53cb728 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/ComposeImageDescriptionFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/ComposeImageDescriptionFragment.java @@ -1,10 +1,18 @@ package org.joinmastodon.android.fragments; import android.app.Activity; -import android.content.res.TypedArray; +import android.content.Context; +import android.graphics.Bitmap; +import android.graphics.Rect; +import android.graphics.drawable.Drawable; +import android.media.MediaMetadataRetriever; import android.net.Uri; +import android.os.Build; import android.os.Bundle; -import android.view.Gravity; +import android.text.SpannableStringBuilder; +import android.text.style.BulletSpan; +import android.util.Log; +import android.view.ContextThemeWrapper; import android.view.LayoutInflater; import android.view.Menu; import android.view.MenuInflater; @@ -12,28 +20,36 @@ import android.view.MenuItem; import android.view.View; import android.view.ViewGroup; import android.view.inputmethod.InputMethodManager; -import android.widget.Button; import android.widget.EditText; -import android.widget.FrameLayout; import android.widget.ImageView; +import org.joinmastodon.android.GlobalUserPreferences; import org.joinmastodon.android.R; -import org.joinmastodon.android.api.requests.statuses.UpdateAttachment; +import org.joinmastodon.android.api.MastodonAPIController; import org.joinmastodon.android.model.Attachment; -import org.parceler.Parcels; +import org.joinmastodon.android.ui.M3AlertDialogBuilder; +import org.joinmastodon.android.ui.photoviewer.PhotoViewer; +import org.joinmastodon.android.ui.utils.ColorPalette; +import org.joinmastodon.android.ui.utils.UiUtils; +import org.joinmastodon.android.ui.views.FixedAspectRatioImageView; -import me.grishka.appkit.Nav; -import me.grishka.appkit.api.Callback; -import me.grishka.appkit.api.ErrorResponse; -import me.grishka.appkit.fragments.ToolbarFragment; +import java.util.Collections; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import me.grishka.appkit.fragments.OnBackPressedListener; import me.grishka.appkit.imageloader.ViewImageLoader; import me.grishka.appkit.imageloader.requests.UrlImageLoaderRequest; import me.grishka.appkit.utils.V; -public class ComposeImageDescriptionFragment extends MastodonToolbarFragment{ +public class ComposeImageDescriptionFragment extends MastodonToolbarFragment implements OnBackPressedListener{ + private static final String TAG="ComposeImageDescription"; + private String accountID, attachmentID; private EditText edit; - private Button saveButton; + private ImageView image; + private ContextThemeWrapper themeWrapper; + private PhotoViewer photoViewer; @Override public void onCreate(Bundle savedInstanceState){ @@ -46,7 +62,14 @@ public class ComposeImageDescriptionFragment extends MastodonToolbarFragment{ @Override public void onAttach(Activity activity){ super.onAttach(activity); - setTitle(R.string.edit_image); + themeWrapper=new ContextThemeWrapper(activity, R.style.Theme_Mastodon_Dark); + ColorPalette.palettes.get(GlobalUserPreferences.color).apply(themeWrapper, GlobalUserPreferences.ThemePreference.DARK); + setTitle(R.string.add_alt_text); + } + + @Override + public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState){ + return super.onCreateView(themeWrapper.getSystemService(LayoutInflater.class), container, savedInstanceState); } @Override @@ -54,14 +77,48 @@ public class ComposeImageDescriptionFragment extends MastodonToolbarFragment{ View view=inflater.inflate(R.layout.fragment_image_description, container, false); edit=view.findViewById(R.id.edit); - ImageView image=view.findViewById(R.id.photo); + image=view.findViewById(R.id.photo); + int width=getArguments().getInt("width", 0); + int height=getArguments().getInt("height", 0); + if(width>0 && height>0){ + // image.setAspectRatio(Math.max(1f, (float)width/height)); + } + image.setOnClickListener(v->openPhotoViewer()); Uri uri=getArguments().getParcelable("uri"); - ViewImageLoader.load(image, null, new UrlImageLoaderRequest(uri, 1000, 1000)); + Attachment.Type type=Attachment.Type.valueOf(getArguments().getString("attachmentType")); + if(type==Attachment.Type.IMAGE) + ViewImageLoader.load(image, null, new UrlImageLoaderRequest(uri, 1000, 1000)); + else + loadVideoThumbIntoView(image, uri); edit.setText(getArguments().getString("existingDescription")); return view; } + private void loadVideoThumbIntoView(ImageView target, Uri uri){ + MastodonAPIController.runInBackground(()->{ + Context context=getActivity(); + if(context==null) + return; + try{ + MediaMetadataRetriever mmr=new MediaMetadataRetriever(); + mmr.setDataSource(context, uri); + Bitmap frame=mmr.getFrameAtTime(3_000_000); + mmr.release(); + int size=Math.max(frame.getWidth(), frame.getHeight()); + int maxSize=V.dp(250); + if(size>maxSize){ + float factor=maxSize/(float)size; + frame=Bitmap.createScaledBitmap(frame, Math.round(frame.getWidth()*factor), Math.round(frame.getHeight()*factor), true); + } + Bitmap finalFrame=frame; + target.post(()->target.setImageBitmap(finalFrame)); + }catch(Exception x){ + Log.w(TAG, "loadVideoThumbIntoView: error getting video frame", x); + } + }); + } + @Override public void onViewCreated(View view, Bundle savedInstanceState){ super.onViewCreated(view, savedInstanceState); @@ -71,43 +128,114 @@ public class ComposeImageDescriptionFragment extends MastodonToolbarFragment{ @Override public void onCreateOptionsMenu(Menu menu, MenuInflater inflater){ - TypedArray ta=getActivity().obtainStyledAttributes(new int[]{R.attr.secondaryButtonStyle}); - int buttonStyle=ta.getResourceId(0, 0); - ta.recycle(); - saveButton=new Button(getActivity(), null, 0, buttonStyle); - saveButton.setText(R.string.save); - saveButton.setOnClickListener(this::onSaveClick); - FrameLayout wrap=new FrameLayout(getActivity()); - wrap.addView(saveButton, new FrameLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT, Gravity.TOP|Gravity.LEFT)); - wrap.setPadding(V.dp(16), V.dp(4), V.dp(16), V.dp(8)); - wrap.setClipToPadding(false); - MenuItem item=menu.add(R.string.publish); - item.setActionView(wrap); - item.setShowAsAction(MenuItem.SHOW_AS_ACTION_ALWAYS); + inflater.inflate(R.menu.compose_image_description, menu); } @Override public boolean onOptionsItemSelected(MenuItem item){ + if(item.getItemId()==R.id.help){ + SpannableStringBuilder msg=new SpannableStringBuilder(getText(R.string.alt_text_help)); + BulletSpan[] spans=msg.getSpans(0, msg.length(), BulletSpan.class); + for(BulletSpan span:spans){ + BulletSpan betterSpan; + if(Build.VERSION.SDK_INT(){ - @Override - public void onSuccess(Attachment result){ - Bundle r=new Bundle(); - r.putParcelable("attachment", Parcels.wrap(result)); - setResult(true, r); - Nav.finish(ComposeImageDescriptionFragment.this); - } + @Override + public boolean onBackPressed(){ + deliverResult(); + return false; + } - @Override - public void onError(ErrorResponse error){ - error.showToast(getActivity()); - } - }) - .wrapProgress(getActivity(), R.string.saving, false) - .exec(accountID); + @Override + protected LayoutInflater getToolbarLayoutInflater(){ + return LayoutInflater.from(themeWrapper); + } + + private void deliverResult(){ + Bundle r=new Bundle(); + r.putString("text", edit.getText().toString().trim()); + r.putString("attachment", attachmentID); + setResult(true, r); + } + + private void openPhotoViewer(){ + Attachment fakeAttachment=new Attachment(); + fakeAttachment.id="local"; + fakeAttachment.type=Attachment.Type.valueOf(getArguments().getString("attachmentType")); + int width=getArguments().getInt("width", 0); + int height=getArguments().getInt("height", 0); + Uri uri=getArguments().getParcelable("uri"); + fakeAttachment.url=uri.toString(); + fakeAttachment.meta=new Attachment.Metadata(); + fakeAttachment.meta.width=width; + fakeAttachment.meta.height=height; + + photoViewer=new PhotoViewer(getActivity(), Collections.singletonList(fakeAttachment), 0, new PhotoViewer.Listener(){ + @Override + public void setPhotoViewVisibility(int index, boolean visible){ + image.setAlpha(visible ? 1f : 0f); + } + + @Override + public boolean startPhotoViewTransition(int index, @NonNull Rect outRect, @NonNull int[] outCornerRadius){ + int[] pos={0, 0}; + image.getLocationOnScreen(pos); + outRect.set(pos[0], pos[1], pos[0]+image.getWidth(), pos[1]+image.getHeight()); + image.setElevation(1f); + return true; + } + + @Override + public void setTransitioningViewTransform(float translateX, float translateY, float scale){ + image.setTranslationX(translateX); + image.setTranslationY(translateY); + image.setScaleX(scale); + image.setScaleY(scale); + } + + @Override + public void endPhotoViewTransition(){ + Drawable d=image.getDrawable(); + image.setImageDrawable(null); + image.setImageDrawable(d); + + image.setTranslationX(0f); + image.setTranslationY(0f); + image.setScaleX(1f); + image.setScaleY(1f); + image.setElevation(0f); + } + + @Nullable + @Override + public Drawable getPhotoViewCurrentDrawable(int index){ + return image.getDrawable(); + } + + @Override + public void photoViewerDismissed(){ + photoViewer=null; + } + + @Override + public void onRequestPermissions(String[] permissions){ + + } + }); + photoViewer.removeMenu(); } } diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/EditTimelinesFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/EditTimelinesFragment.java index 694dec11c..b64fcd431 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/EditTimelinesFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/EditTimelinesFragment.java @@ -17,7 +17,6 @@ import android.view.MotionEvent; import android.view.SubMenu; import android.view.View; import android.view.ViewGroup; -import android.view.inputmethod.InputMethodManager; import android.widget.Button; import android.widget.EditText; import android.widget.FrameLayout; @@ -37,10 +36,11 @@ import androidx.recyclerview.widget.RecyclerView; import com.hootsuite.nachos.NachoTextView; -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.api.session.AccountLocalPreferences; +import org.joinmastodon.android.api.session.AccountSessionManager; import org.joinmastodon.android.api.session.AccountSession; import org.joinmastodon.android.api.session.AccountSessionManager; import org.joinmastodon.android.model.CustomLocalTimeline; @@ -58,6 +58,7 @@ import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.function.Consumer; import me.grishka.appkit.api.Callback; @@ -66,7 +67,7 @@ import me.grishka.appkit.utils.BindableViewHolder; import me.grishka.appkit.utils.V; import me.grishka.appkit.views.UsableRecyclerView; -public class EditTimelinesFragment extends RecyclerFragment implements ScrollableToTop { +public class EditTimelinesFragment extends MastodonRecyclerFragment implements ScrollableToTop { private String accountID; private TimelinesAdapter adapter; private final ItemTouchHelper itemTouchHelper; @@ -129,7 +130,7 @@ public class EditTimelinesFragment extends RecyclerFragment super.onViewCreated(view, savedInstanceState); itemTouchHelper.attachToRecyclerView(list); refreshLayout.setEnabled(false); - list.addItemDecoration(new DividerItemDecoration(getActivity(), R.attr.colorPollVoted, 0.5f, 56, 16)); + list.addItemDecoration(new DividerItemDecoration(getActivity(), R.attr.colorM3OutlineVariant, 0.5f, 56, 16)); } @Override @@ -222,7 +223,7 @@ public class EditTimelinesFragment extends RecyclerFragment makeBackItem(listsMenu); makeBackItem(hashtagsMenu); - TimelineDefinition.getAllTimelines(accountID).forEach(tl -> addTimelineToOptions(tl, timelinesMenu)); + TimelineDefinition.getAllTimelines(accountID).stream().forEach(tl -> addTimelineToOptions(tl, timelinesMenu)); listTimelines.stream().map(TimelineDefinition::ofList).forEach(tl -> addTimelineToOptions(tl, listsMenu)); addHashtagItem = addOptionsItem(hashtagsMenu, getContext().getString(R.string.sk_timelines_add), R.drawable.ic_fluent_add_24_regular); hashtags.stream().map(TimelineDefinition::ofHashtag).forEach(tl -> addTimelineToOptions(tl, hashtagsMenu)); @@ -235,10 +236,12 @@ public class EditTimelinesFragment extends RecyclerFragment } private void saveTimelines() { - updated = true; - GlobalUserPreferences.pinnedTimelines.put(accountID, data.size() > 0 ? data : List.of(TimelineDefinition.HOME_TIMELINE)); - GlobalUserPreferences.save(); - } + updated=true; + AccountLocalPreferences prefs=AccountSessionManager.get(accountID).getLocalPreferences(); + if(data.isEmpty()) data.add(TimelineDefinition.HOME_TIMELINE); + prefs.timelines=data; + prefs.save(); + } private void removeTimeline(int position) { data.remove(position); @@ -249,7 +252,7 @@ public class EditTimelinesFragment extends RecyclerFragment @Override protected void doLoadData(int offset, int count){ - onDataLoaded(GlobalUserPreferences.pinnedTimelines.getOrDefault(accountID, TimelineDefinition.getDefaultTimelines(accountID)), false); + onDataLoaded(AccountSessionManager.get(accountID).getLocalPreferences().timelines); updateOptionsMenu(); } @@ -271,21 +274,16 @@ public class EditTimelinesFragment extends RecyclerFragment private boolean setTagListContent(NachoTextView editText, @Nullable List tags) { if (tags == null || tags.isEmpty()) return false; - editText.setText(tags); + editText.setText(String.join(",", tags)); editText.chipifyAllUnterminatedTokens(); return true; } private NachoTextView prepareChipTextView(NachoTextView nacho) { - //I’ll Be Back - nacho.setChipTerminators( - Map.of( - ',', BEHAVIOR_CHIPIFY_ALL, - '\n', BEHAVIOR_CHIPIFY_ALL, - ' ', BEHAVIOR_CHIPIFY_ALL, - ';', BEHAVIOR_CHIPIFY_ALL - ) - ); + nacho.addChipTerminator(',', BEHAVIOR_CHIPIFY_ALL); + nacho.addChipTerminator('\n', BEHAVIOR_CHIPIFY_ALL); + nacho.addChipTerminator(' ', BEHAVIOR_CHIPIFY_ALL); + nacho.addChipTerminator(';', BEHAVIOR_CHIPIFY_ALL); nacho.enableEditChipOnTouch(true, true); nacho.setOnFocusChangeListener((v, hasFocus) -> nacho.chipifyAllUnterminatedTokens()); return nacho; @@ -296,7 +294,8 @@ public class EditTimelinesFragment extends RecyclerFragment Context ctx = getContext(); View view = getActivity().getLayoutInflater().inflate(R.layout.edit_timeline, list, false); - Button advancedBtn = view.findViewById(R.id.advanced); + View divider = view.findViewById(R.id.divider); + Button advancedBtn = view.findViewById(R.id.advanced); EditText editText = view.findViewById(R.id.input); if (item != null) editText.setText(item.getCustomTitle()); editText.setHint(item != null ? item.getDefaultTitle(ctx) : ctx.getString(R.string.sk_hashtag)); @@ -304,11 +303,12 @@ public class EditTimelinesFragment extends RecyclerFragment LinearLayout tagWrap = view.findViewById(R.id.tag_wrap); boolean advancedOptionsAvailable = item == null || item.getType() == TimelineDefinition.TimelineType.HASHTAG; advancedBtn.setVisibility(advancedOptionsAvailable ? View.VISIBLE : View.GONE); - view.findViewById(R.id.divider).setVisibility(advancedOptionsAvailable ? View.VISIBLE : View.GONE); advancedBtn.setOnClickListener(l -> { advancedBtn.setSelected(!advancedBtn.isSelected()); - advancedBtn.setText(advancedBtn.isSelected() ? R.string.sk_advanced_options_hide : R.string.sk_advanced_options_show); + advancedBtn.setText(advancedBtn.isSelected() ? R.string.sk_advanced_options_hide : R.string.sk_advanced_options_show); + divider.setVisibility(advancedBtn.isSelected() ? View.VISIBLE : View.GONE); tagWrap.setVisibility(advancedBtn.isSelected() ? View.VISIBLE : View.GONE); + UiUtils.beginLayoutTransition((ViewGroup) view); }); Switch localOnlySwitch = view.findViewById(R.id.local_only_switch); @@ -321,8 +321,9 @@ public class EditTimelinesFragment extends RecyclerFragment NachoTextView tagsNone = prepareChipTextView(view.findViewById(R.id.tags_none)); if (item != null) { tagMain.setText(item.getHashtagName()); - boolean hasAdvanced = setTagListContent(tagsAny, item.getHashtagAny()); - hasAdvanced = setTagListContent(tagsAll, item.getHashtagAll()) || hasAdvanced; + boolean hasAdvanced = !TextUtils.isEmpty(item.getCustomTitle()) && !Objects.equals(item.getHashtagName(), item.getCustomTitle()); + hasAdvanced = setTagListContent(tagsAny, item.getHashtagAny()) || hasAdvanced; + hasAdvanced = setTagListContent(tagsAll, item.getHashtagAll()) || hasAdvanced; hasAdvanced = setTagListContent(tagsNone, item.getHashtagNone()) || hasAdvanced; if (item.isHashtagLocalOnly()) { localOnlySwitch.setChecked(true); @@ -331,7 +332,8 @@ public class EditTimelinesFragment extends RecyclerFragment if (hasAdvanced) { advancedBtn.setSelected(true); advancedBtn.setText(R.string.sk_advanced_options_hide); - tagWrap.setVisibility(View.VISIBLE); + tagWrap.setVisibility(View.VISIBLE); + divider.setVisibility(View.VISIBLE); } } 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 e649bc7ba..d5c2577af 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/FavoritedStatusListFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/FavoritedStatusListFragment.java @@ -5,7 +5,7 @@ import android.net.Uri; import org.joinmastodon.android.R; import org.joinmastodon.android.api.requests.statuses.GetFavoritedStatuses; -import org.joinmastodon.android.model.Filter; +import org.joinmastodon.android.model.FilterContext; import org.joinmastodon.android.model.HeaderPaginationList; import org.joinmastodon.android.model.Status; @@ -39,8 +39,8 @@ public class FavoritedStatusListFragment extends StatusListFragment{ } @Override - protected Filter.FilterContext getFilterContext() { - return Filter.FilterContext.ACCOUNT; + protected FilterContext getFilterContext() { + return FilterContext.ACCOUNT; } @Override diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/FeaturedHashtagsListFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/FeaturedHashtagsListFragment.java new file mode 100644 index 000000000..45d0e58c1 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/FeaturedHashtagsListFragment.java @@ -0,0 +1,64 @@ +package org.joinmastodon.android.fragments; + +import android.graphics.Canvas; +import android.graphics.Paint; +import android.net.Uri; +import android.os.Bundle; +import android.view.View; + +import org.joinmastodon.android.R; +import org.joinmastodon.android.model.Account; +import org.joinmastodon.android.model.Hashtag; +import org.joinmastodon.android.ui.displayitems.HashtagStatusDisplayItem; +import org.joinmastodon.android.ui.displayitems.StatusDisplayItem; +import org.joinmastodon.android.ui.utils.UiUtils; +import org.parceler.Parcels; + +import java.util.Collections; +import java.util.List; +import java.util.Objects; +import java.util.stream.Collectors; + +import androidx.recyclerview.widget.RecyclerView; + +public class FeaturedHashtagsListFragment extends BaseStatusListFragment{ + private Account account; + private String accountID; + + @Override + public void onCreate(Bundle savedInstanceState){ + super.onCreate(savedInstanceState); + accountID=getArguments().getString("account"); + account=Parcels.unwrap(getArguments().getParcelable("profileAccount")); + onDataLoaded(getArguments().getParcelableArrayList("hashtags").stream().map(p->(Hashtag)Parcels.unwrap(p)).collect(Collectors.toList()), false); + setTitle(R.string.hashtags); + } + + @Override + protected List buildDisplayItems(Hashtag s){ + return Collections.singletonList(new HashtagStatusDisplayItem(s.name, this, s)); + } + + @Override + protected void addAccountToKnown(Hashtag s){ + + } + + @Override + public void onItemClick(String id){ + UiUtils.openHashtagTimeline(getActivity(), accountID, id, data.stream().filter(h -> Objects.equals(h.name, id)).findAny().map(h -> h.following).orElse(null)); + } + + @Override + protected void doLoadData(int offset, int count){} + + @Override + protected void drawDivider(View child, View bottomSibling, RecyclerView.ViewHolder holder, RecyclerView.ViewHolder siblingHolder, RecyclerView parent, Canvas c, Paint paint){ + // no-op + } + + @Override + public Uri getWebUri(Uri.Builder base){ + return null; // TODO + } +} 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 17e9ce0de..d5cdf7cc3 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/FollowRequestsListFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/FollowRequestsListFragment.java @@ -48,7 +48,7 @@ import me.grishka.appkit.utils.BindableViewHolder; import me.grishka.appkit.utils.V; import me.grishka.appkit.views.UsableRecyclerView; -public class FollowRequestsListFragment extends RecyclerFragment implements ScrollableToTop, ProvidesAssistContent.ProvidesWebUri { +public class FollowRequestsListFragment extends MastodonRecyclerFragment implements ScrollableToTop, ProvidesAssistContent.ProvidesWebUri { private String accountID; private Map relationships=Collections.emptyMap(); private GetAccountRelationships relationshipsRequest; @@ -254,7 +254,7 @@ public class FollowRequestsListFragment extends RecyclerFragment { + if(getContext()==null) return; itemView.setHasTransientState(false); relationships.put(item.account.id, rel); RecyclerView.Adapter adapter = getBindingAdapter(); @@ -328,6 +329,7 @@ public class FollowRequestsListFragment extends RecyclerFragment{ + if(getContext()==null) return; itemView.setHasTransientState(false); relationships.put(item.account.id, rel); rebind(); 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 c7b49da74..bdccae2b5 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/FollowedHashtagsFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/FollowedHashtagsFragment.java @@ -21,7 +21,7 @@ import me.grishka.appkit.api.SimpleCallback; import me.grishka.appkit.utils.BindableViewHolder; import me.grishka.appkit.views.UsableRecyclerView; -public class FollowedHashtagsFragment extends RecyclerFragment implements ScrollableToTop, ProvidesAssistContent.ProvidesWebUri { +public class FollowedHashtagsFragment extends MastodonRecyclerFragment implements ScrollableToTop, ProvidesAssistContent.ProvidesWebUri { private String nextMaxID; private String accountID; @@ -47,7 +47,7 @@ public class FollowedHashtagsFragment extends RecyclerFragment implemen @Override public void onViewCreated(View view, Bundle savedInstanceState) { super.onViewCreated(view, savedInstanceState); - list.addItemDecoration(new DividerItemDecoration(getActivity(), R.attr.colorPollVoted, 0.5f, 56, 16)); + list.addItemDecoration(new DividerItemDecoration(getActivity(), R.attr.colorM3OutlineVariant, 0.5f, 56, 16)); } @Override diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/HasAccountID.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/HasAccountID.java index 5e7f01fed..9934b2e17 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/HasAccountID.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/HasAccountID.java @@ -1,5 +1,6 @@ package org.joinmastodon.android.fragments; +import org.joinmastodon.android.api.session.AccountLocalPreferences; import org.joinmastodon.android.api.session.AccountSession; import org.joinmastodon.android.api.session.AccountSessionManager; import org.joinmastodon.android.model.Instance; @@ -24,4 +25,8 @@ public interface HasAccountID { default Optional getInstance() { return getSession().getInstance(); } + + default AccountLocalPreferences getLocalPrefs() { + return AccountSessionManager.get(getAccountID()).getLocalPreferences(); + } } diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/HasElevationOnScrollListener.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/HasElevationOnScrollListener.java new file mode 100644 index 000000000..0a110af44 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/HasElevationOnScrollListener.java @@ -0,0 +1,7 @@ +package org.joinmastodon.android.fragments; + +import org.joinmastodon.android.utils.ElevationOnScrollListener; + +public interface HasElevationOnScrollListener { + ElevationOnScrollListener getElevationOnScrollListener(); +} 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 b5c0077ff..6670b3480 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/HashtagTimelineFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/HashtagTimelineFragment.java @@ -18,7 +18,7 @@ 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.FilterContext; import org.joinmastodon.android.model.Hashtag; import org.joinmastodon.android.model.Status; import org.joinmastodon.android.model.TimelineDefinition; @@ -134,7 +134,7 @@ public class HashtagTimelineFragment extends PinnableStatusListFragment { @Override protected void doLoadData(int offset, int count){ - currentRequest=new GetHashtagTimeline(hashtag, offset==0 ? null : getMaxID(), null, count, any, all, none, localOnly) + currentRequest=new GetHashtagTimeline(hashtag, offset==0 ? null : getMaxID(), null, count, any, all, none, localOnly, getLocalPrefs().timelineReplyVisibility) .setCallback(new SimpleCallback<>(this){ @Override public void onSuccess(List result){ @@ -168,12 +168,12 @@ public class HashtagTimelineFragment extends PinnableStatusListFragment { @Override protected void onSetFabBottomInset(int inset){ - ((ViewGroup.MarginLayoutParams) fab.getLayoutParams()).bottomMargin=V.dp(24)+inset; + ((ViewGroup.MarginLayoutParams) fab.getLayoutParams()).bottomMargin=V.dp(16)+inset; } @Override - protected Filter.FilterContext getFilterContext() { - return Filter.FilterContext.PUBLIC; + protected FilterContext getFilterContext() { + return FilterContext.PUBLIC; } @Override 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 7e6e7d7f8..c4af72191 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/HomeFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/HomeFragment.java @@ -1,8 +1,10 @@ package org.joinmastodon.android.fragments; +import android.annotation.SuppressLint; import android.app.Fragment; import android.app.NotificationManager; import android.app.assist.AssistContent; +import android.graphics.drawable.RippleDrawable; import android.content.Intent; import android.graphics.Outline; import android.os.Build; @@ -11,45 +13,41 @@ import android.service.notification.StatusBarNotification; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; -import android.view.ViewOutlineProvider; import android.view.ViewTreeObserver; import android.view.WindowInsets; import android.widget.FrameLayout; import android.widget.ImageView; import android.widget.LinearLayout; +import android.widget.TextView; import androidx.annotation.IdRes; import androidx.annotation.Nullable; import com.squareup.otto.Subscribe; -import org.joinmastodon.android.DomainManager; +import org.joinmastodon.android.E; import org.joinmastodon.android.GlobalUserPreferences; -import org.joinmastodon.android.MainActivity; -import org.joinmastodon.android.E; -import org.joinmastodon.android.E; import org.joinmastodon.android.R; -import org.joinmastodon.android.api.requests.notifications.GetNotifications; import org.joinmastodon.android.api.session.AccountSession; import org.joinmastodon.android.api.session.AccountSessionManager; -import org.joinmastodon.android.events.AllNotificationsSeenEvent; -import org.joinmastodon.android.events.NotificationReceivedEvent; -import org.joinmastodon.android.fragments.discover.DiscoverAccountsFragment; +import org.joinmastodon.android.events.NotificationsMarkerUpdatedEvent; +import org.joinmastodon.android.events.StatusDisplaySettingsChangedEvent; import org.joinmastodon.android.fragments.discover.DiscoverFragment; import org.joinmastodon.android.fragments.onboarding.OnboardingFollowSuggestionsFragment; import org.joinmastodon.android.model.Account; import org.joinmastodon.android.model.Instance; import org.joinmastodon.android.model.Notification; +import org.joinmastodon.android.model.PaginatedResponse; import org.joinmastodon.android.ui.AccountSwitcherSheet; +import org.joinmastodon.android.ui.OutlineProviders; import org.joinmastodon.android.ui.utils.UiUtils; import org.joinmastodon.android.ui.views.TabBar; +import org.joinmastodon.android.utils.ObjectIdComparator; import org.joinmastodon.android.utils.ProvidesAssistContent; import org.parceler.Parcels; import java.util.ArrayList; -import java.util.EnumSet; import java.util.List; -import java.util.Optional; import me.grishka.appkit.FragmentStackActivity; import me.grishka.appkit.Nav; @@ -67,42 +65,39 @@ public class HomeFragment extends AppKitFragment implements OnBackPressedListene private FragmentRootLinearLayout content; private HomeTabFragment homeTabFragment; private NotificationsFragment notificationsFragment; - private DiscoverFragment searchFragment; + private DiscoverFragment discoverFragment; private ProfileFragment profileFragment; private TabBar tabBar; private View tabBarWrap; private ImageView tabBarAvatar; - private ImageView notificationTabIcon; @IdRes private int currentTab=R.id.tab_home; + private TextView notificationsBadge; private String accountID; - private boolean isPleroma; + private boolean isAkkoma; @Override public void onCreate(Bundle savedInstanceState){ super.onCreate(savedInstanceState); - E.register(this); accountID=getArguments().getString("account"); setTitle(R.string.mo_app_name); - isPleroma = AccountSessionManager.getInstance().getAccount(accountID).getInstance() - .map(Instance::isAkkoma) - .orElse(false); + + isAkkoma = getInstance().map(Instance::isAkkoma).orElse(false); if(Build.VERSION.SDK_INT>=Build.VERSION_CODES.N) setRetainInstance(true); - // TODO: clean up if(savedInstanceState==null){ Bundle args=new Bundle(); args.putString("account", accountID); homeTabFragment=new HomeTabFragment(); homeTabFragment.setArguments(args); args=new Bundle(args); - args.putBoolean("disableDiscover", isPleroma); + args.putBoolean("disableDiscover", isAkkoma); args.putBoolean("noAutoLoad", true); - searchFragment=new DiscoverFragment(); - searchFragment.setArguments(args); + discoverFragment=new DiscoverFragment(); + discoverFragment.setArguments(args); notificationsFragment=new NotificationsFragment(); notificationsFragment.setArguments(args); args=new Bundle(args); @@ -112,6 +107,13 @@ public class HomeFragment extends AppKitFragment implements OnBackPressedListene profileFragment.setArguments(args); } + E.register(this); + } + + @Override + public void onDestroy(){ + super.onDestroy(); + E.unregister(this); } @Nullable @@ -129,24 +131,47 @@ public class HomeFragment extends AppKitFragment implements OnBackPressedListene tabBar.setListeners(this::onTabSelected, this::onTabLongClick); tabBarWrap=content.findViewById(R.id.tabbar_wrap); - tabBarAvatar=tabBar.findViewById(R.id.tab_profile_ava); - tabBarAvatar.setOutlineProvider(new ViewOutlineProvider(){ - @Override - public void getOutline(View view, Outline outline){ - outline.setOval(0, 0, view.getWidth(), view.getHeight()); + // this one's for the pill haters (https://m3.material.io/components/navigation-bar/overview) + if(GlobalUserPreferences.disableM3PillActiveIndicator){ + tabBar.findViewById(R.id.tab_home_pill).setBackground(null); + tabBar.findViewById(R.id.tab_search_pill).setBackground(null); + tabBar.findViewById(R.id.tab_notifications_pill).setBackground(null); + tabBar.findViewById(R.id.tab_profile_pill).setBackgroundResource(R.drawable.bg_tab_profile); + + View[] tabs={ + tabBar.findViewById(R.id.tab_home), + tabBar.findViewById(R.id.tab_search), + tabBar.findViewById(R.id.tab_notifications), + tabBar.findViewById(R.id.tab_profile) + }; + + for(View tab : tabs){ + tab.setBackgroundResource(R.drawable.bg_tabbar_tab_ripple); + ((RippleDrawable) tab.getBackground()) + .setRadius(V.dp(GlobalUserPreferences.showNavigationLabels ? 56 : 42)); } - }); + } + + if(!GlobalUserPreferences.showNavigationLabels){ + tabBar.findViewById(R.id.tab_home_label).setVisibility(View.GONE); + tabBar.findViewById(R.id.tab_search_label).setVisibility(View.GONE); + tabBar.findViewById(R.id.tab_notifications_label).setVisibility(View.GONE); + tabBar.findViewById(R.id.tab_profile_label).setVisibility(View.GONE); + } + + tabBarAvatar=tabBar.findViewById(R.id.tab_profile_ava); + tabBarAvatar.setOutlineProvider(OutlineProviders.OVAL); tabBarAvatar.setClipToOutline(true); Account self=AccountSessionManager.getInstance().getAccount(accountID).self; - ViewImageLoader.load(tabBarAvatar, null, new UrlImageLoaderRequest(self.avatar, V.dp(28), V.dp(28))); + ViewImageLoader.loadWithoutAnimation(tabBarAvatar, null, new UrlImageLoaderRequest(self.avatar, V.dp(24), V.dp(24))); - notificationTabIcon=content.findViewById(R.id.tab_notifications); - updateNotificationBadge(); + notificationsBadge=tabBar.findViewById(R.id.notifications_badge); + notificationsBadge.setVisibility(View.GONE); if(savedInstanceState==null){ getChildFragmentManager().beginTransaction() .add(me.grishka.appkit.R.id.fragment_wrap, homeTabFragment) - .add(me.grishka.appkit.R.id.fragment_wrap, searchFragment).hide(searchFragment) + .add(me.grishka.appkit.R.id.fragment_wrap, discoverFragment).hide(discoverFragment) .add(me.grishka.appkit.R.id.fragment_wrap, notificationsFragment).hide(notificationsFragment) .add(me.grishka.appkit.R.id.fragment_wrap, profileFragment).hide(profileFragment) .commit(); @@ -173,7 +198,7 @@ public class HomeFragment extends AppKitFragment implements OnBackPressedListene super.onViewStateRestored(savedInstanceState); if(savedInstanceState==null) return; homeTabFragment=(HomeTabFragment) getChildFragmentManager().getFragment(savedInstanceState, "homeTabFragment"); - searchFragment=(DiscoverFragment) getChildFragmentManager().getFragment(savedInstanceState, "searchFragment"); + discoverFragment=(DiscoverFragment) getChildFragmentManager().getFragment(savedInstanceState, "searchFragment"); notificationsFragment=(NotificationsFragment) getChildFragmentManager().getFragment(savedInstanceState, "notificationsFragment"); profileFragment=(ProfileFragment) getChildFragmentManager().getFragment(savedInstanceState, "profileFragment"); currentTab=savedInstanceState.getInt("selectedTab"); @@ -181,7 +206,7 @@ public class HomeFragment extends AppKitFragment implements OnBackPressedListene Fragment current=fragmentForTab(currentTab); getChildFragmentManager().beginTransaction() .hide(homeTabFragment) - .hide(searchFragment) + .hide(discoverFragment) .hide(notificationsFragment) .hide(profileFragment) .show(current) @@ -197,7 +222,7 @@ public class HomeFragment extends AppKitFragment implements OnBackPressedListene @Override public boolean wantsLightStatusBar(){ - return currentTab!=R.id.tab_profile && !UiUtils.isDarkTheme(); + return !UiUtils.isDarkTheme(); } @Override @@ -209,14 +234,14 @@ public class HomeFragment extends AppKitFragment implements OnBackPressedListene public void onApplyWindowInsets(WindowInsets insets){ if(Build.VERSION.SDK_INT>=27){ int inset=insets.getSystemWindowInsetBottom(); - tabBarWrap.setPadding(0, 0, 0, inset>0 ? Math.max(inset, V.dp(36)) : 0); + tabBarWrap.setPadding(0, 0, 0, inset>0 ? Math.max(inset, V.dp(24)) : 0); super.onApplyWindowInsets(insets.replaceSystemWindowInsets(insets.getSystemWindowInsetLeft(), 0, insets.getSystemWindowInsetRight(), 0)); }else{ super.onApplyWindowInsets(insets.replaceSystemWindowInsets(insets.getSystemWindowInsetLeft(), 0, insets.getSystemWindowInsetRight(), insets.getSystemWindowInsetBottom())); } WindowInsets topOnlyInsets=insets.replaceSystemWindowInsets(0, insets.getSystemWindowInsetTop(), 0, 0); homeTabFragment.onApplyWindowInsets(topOnlyInsets); - searchFragment.onApplyWindowInsets(topOnlyInsets); + discoverFragment.onApplyWindowInsets(topOnlyInsets); notificationsFragment.onApplyWindowInsets(topOnlyInsets); profileFragment.onApplyWindowInsets(topOnlyInsets); } @@ -225,7 +250,7 @@ public class HomeFragment extends AppKitFragment implements OnBackPressedListene if(tab==R.id.tab_home){ return homeTabFragment; }else if(tab==R.id.tab_search){ - return searchFragment; + return discoverFragment; }else if(tab==R.id.tab_notifications){ return notificationsFragment; }else if(tab==R.id.tab_profile){ @@ -255,7 +280,6 @@ public class HomeFragment extends AppKitFragment implements OnBackPressedListene if (newFragment instanceof HasFab fabulous && !fabulous.isScrolling()) fabulous.showFab(); currentTab=tab; ((FragmentStackActivity)getActivity()).invalidateSystemBarColors(this); - if (tab == R.id.tab_search && isPleroma) searchFragment.selectSearch(); } private void maybeTriggerLoading(Fragment newFragment){ @@ -266,7 +290,6 @@ public class HomeFragment extends AppKitFragment implements OnBackPressedListene ((DiscoverFragment) newFragment).loadData(); }else if(newFragment instanceof NotificationsFragment){ ((NotificationsFragment) newFragment).loadData(); - // TODO make an interface? NotificationManager nm=getActivity().getSystemService(NotificationManager.class); for (StatusBarNotification notification : nm.getActiveNotifications()) { if (accountID.equals(notification.getTag())) { @@ -288,7 +311,7 @@ public class HomeFragment extends AppKitFragment implements OnBackPressedListene if(tab==R.id.tab_search){ onTabSelected(R.id.tab_search); tabBar.selectTab(R.id.tab_search); - searchFragment.selectSearch(); + searchFragment.openSearch(); return true; } if(tab==R.id.tab_home){ @@ -304,7 +327,7 @@ public class HomeFragment extends AppKitFragment implements OnBackPressedListene if(currentTab==R.id.tab_profile) if (profileFragment.onBackPressed()) return true; if(currentTab==R.id.tab_search) - if (searchFragment.onBackPressed()) return true; + if (discoverFragment.onBackPressed()) return true; if (currentTab!=R.id.tab_home) { tabBar.selectTab(R.id.tab_home); onTabSelected(R.id.tab_home); @@ -319,52 +342,79 @@ public class HomeFragment extends AppKitFragment implements OnBackPressedListene super.onSaveInstanceState(outState); outState.putInt("selectedTab", currentTab); if (homeTabFragment.isAdded()) getChildFragmentManager().putFragment(outState, "homeTabFragment", homeTabFragment); - if (searchFragment.isAdded()) getChildFragmentManager().putFragment(outState, "searchFragment", searchFragment); + if (discoverFragment.isAdded()) getChildFragmentManager().putFragment(outState, "searchFragment", discoverFragment); if (notificationsFragment.isAdded()) getChildFragmentManager().putFragment(outState, "notificationsFragment", notificationsFragment); if (profileFragment.isAdded()) getChildFragmentManager().putFragment(outState, "profileFragment", profileFragment); } - public void updateNotificationBadge() { - AccountSession session = AccountSessionManager.getInstance().getAccount(accountID); - Optional instance = session.getInstance(); - if (instance.isEmpty()) return; // avoiding incompatibility with akkoma - - new GetNotifications(null, 1, EnumSet.allOf(Notification.Type.class), instance.get().isAkkoma()) - .setCallback(new Callback<>() { - @Override - public void onSuccess(List notifications) { - if (notifications.size() > 0) { - try { - long newestId = Long.parseLong(notifications.get(0).id); - long lastSeenId = Long.parseLong(session.markers.notifications.lastReadId); - setNotificationBadge(newestId > lastSeenId); - } catch (Exception ignored) { - setNotificationBadge(false); - } - } - } - - @Override - public void onError(ErrorResponse error) { - setNotificationBadge(false); - } - }).exec(accountID); + @Override + protected void onShown(){ + super.onShown(); + reloadNotificationsForUnreadCount(); } - public void setNotificationBadge(boolean badge) { - notificationTabIcon.setImageResource(badge - ? R.drawable.ic_fluent_alert_28_selector_badged - : R.drawable.ic_fluent_alert_28_selector); + public void reloadNotificationsForUnreadCount(){ + List[] notifications=new List[]{null}; + String[] marker={null}; + + AccountSessionManager.get(accountID).reloadNotificationsMarker(m->{ + marker[0]=m; + if(notifications[0]!=null){ + updateUnreadCount(notifications[0], marker[0]); + } + }); + + AccountSessionManager.get(accountID).getCacheController().getNotifications(null, 40, false, false, true, new Callback<>(){ + @Override + public void onSuccess(PaginatedResponse> result){ + notifications[0]=result.items; + if(marker[0]!=null) + updateUnreadCount(notifications[0], marker[0]); + } + + @Override + public void onError(ErrorResponse error){} + }); + } + + @SuppressLint("DefaultLocale") + private void updateUnreadCount(List notifications, String marker){ + if(notifications.isEmpty() || ObjectIdComparator.INSTANCE.compare(notifications.get(0).id, marker)<=0){ + V.setVisibilityAnimated(notificationsBadge, View.GONE); + }else{ + V.setVisibilityAnimated(notificationsBadge, View.VISIBLE); + if(ObjectIdComparator.INSTANCE.compare(notifications.get(notifications.size()-1).id, marker)>0){ + notificationsBadge.setText(String.format("%d+", notifications.size())); + }else{ + int count=0; + for(Notification n:notifications){ + if(n.id.equals(marker)) + break; + count++; + } + notificationsBadge.setText(String.format("%d", count)); + } + } } @Subscribe - public void onNotificationReceived(NotificationReceivedEvent notificationReceivedEvent) { - if (notificationReceivedEvent.account.equals(accountID)) setNotificationBadge(true); + public void onNotificationsMarkerUpdated(NotificationsMarkerUpdatedEvent ev){ + if(!ev.accountID.equals(accountID)) + return; + if(ev.clearUnread) + V.setVisibilityAnimated(notificationsBadge, View.GONE); } @Subscribe - public void onAllNotificationsSeen(AllNotificationsSeenEvent allNotificationsSeenEvent) { - setNotificationBadge(false); + public void onStatusDisplaySettingsChanged(StatusDisplaySettingsChangedEvent ev){ + if(!ev.accountID.equals(accountID)) + return; + if(homeTabFragment.getCurrentFragment() instanceof LoaderFragment lf && lf.loaded + && lf instanceof BaseStatusListFragment homeTimelineFragment) + homeTimelineFragment.rebuildAllDisplayItems(); + if(notificationsFragment.getCurrentFragment() instanceof LoaderFragment lf && lf.loaded + && lf instanceof BaseStatusListFragment l) + l.rebuildAllDisplayItems(); } @Override 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 ab98e6180..c08f2f4a9 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/HomeTabFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/HomeTabFragment.java @@ -12,6 +12,7 @@ import android.app.Fragment; import android.app.FragmentTransaction; import android.app.assist.AssistContent; import android.content.Context; +import android.content.res.Configuration; import android.os.Build; import android.os.Bundle; import android.view.LayoutInflater; @@ -37,13 +38,13 @@ import androidx.viewpager2.widget.ViewPager2; import com.squareup.otto.Subscribe; -import org.joinmastodon.android.DomainManager; import org.joinmastodon.android.E; import org.joinmastodon.android.GlobalUserPreferences; 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.api.session.AccountSessionManager; import org.joinmastodon.android.events.HashtagUpdatedEvent; import org.joinmastodon.android.events.ListDeletedEvent; import org.joinmastodon.android.events.ListUpdatedCreatedEvent; @@ -57,9 +58,11 @@ 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 org.joinmastodon.android.utils.ElevationOnScrollListener; import org.joinmastodon.android.utils.ProvidesAssistContent; import java.util.Collection; +import java.util.Comparator; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -74,8 +77,9 @@ import me.grishka.appkit.fragments.BaseRecyclerFragment; import me.grishka.appkit.fragments.OnBackPressedListener; import me.grishka.appkit.utils.CubicBezierInterpolator; import me.grishka.appkit.utils.V; +import me.grishka.appkit.views.FragmentRootLinearLayout; -public class HomeTabFragment extends MastodonToolbarFragment implements ScrollableToTop, OnBackPressedListener, HasFab, ProvidesAssistContent { +public class HomeTabFragment extends MastodonToolbarFragment implements ScrollableToTop, OnBackPressedListener, HasFab, ProvidesAssistContent, HasElevationOnScrollListener { private static final int ANNOUNCEMENTS_RESULT = 654; private String accountID; @@ -93,7 +97,7 @@ 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 List timelinesList; private int count; private Fragment[] fragments; private FrameLayout[] tabViews; @@ -104,6 +108,7 @@ public class HomeTabFragment extends MastodonToolbarFragment implements Scrollab private View overflowActionView = null; private boolean announcementsBadged, settingsBadged; private ImageButton fab; + private ElevationOnScrollListener elevationOnScrollListener; @Override public void onCreate(Bundle savedInstanceState) { @@ -131,7 +136,11 @@ public class HomeTabFragment extends MastodonToolbarFragment implements Scrollab @Override public View onCreateContentView(LayoutInflater inflater, ViewGroup container, Bundle bundle) { + FragmentRootLinearLayout rootView = new FragmentRootLinearLayout(getContext()); + rootView.setLayoutParams(new ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT)); FrameLayout view = new FrameLayout(getContext()); + view.setLayoutParams(new ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT)); + rootView.addView(view); inflater.inflate(R.layout.compose_fab, view); fab = view.findViewById(R.id.fab); fab.setOnClickListener(this::onFabClick); @@ -146,8 +155,8 @@ public class HomeTabFragment extends MastodonToolbarFragment implements Scrollab args.putBoolean("__disable_fab", true); args.putBoolean("onlyPosts", true); - for (int i = 0; i < timelineDefinitions.size(); i++) { - TimelineDefinition tl = timelineDefinitions.get(i); + for (int i=0; i < timelinesList.size(); i++) { + TimelineDefinition tl = timelinesList.get(i); fragments[i] = tl.getFragment(); timelines[i] = tl; } @@ -174,7 +183,7 @@ public class HomeTabFragment extends MastodonToolbarFragment implements Scrollab overflowActionView.setOnClickListener(l -> overflowPopup.show()); overflowActionView.setOnTouchListener(overflowPopup.getDragToOpenListener()); - return view; + return rootView; } @SuppressLint("ClickableViewAccessibility") @@ -249,6 +258,8 @@ public class HomeTabFragment extends MastodonToolbarFragment implements Scrollab }); } + elevationOnScrollListener = new ElevationOnScrollListener((FragmentRootLinearLayout) view, getToolbar()); + if(GithubSelfUpdater.needSelfUpdating()){ updateUpdateState(GithubSelfUpdater.getInstance().getState()); } @@ -295,6 +306,10 @@ public class HomeTabFragment extends MastodonToolbarFragment implements Scrollab }).exec(accountID); } + public ElevationOnScrollListener getElevationOnScrollListener() { + return elevationOnScrollListener; + } + private void onFabClick(View v){ if (fragments[pager.getCurrentItem()] instanceof BaseStatusListFragment l) { l.onFabClick(v); @@ -326,11 +341,13 @@ public class HomeTabFragment extends MastodonToolbarFragment implements Scrollab 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); - }); + hashtagsItems.entrySet().stream() + .sorted(Comparator.comparing(x -> x.getValue().name, String.CASE_INSENSITIVE_ORDER)) + .forEach(entry -> { + MenuItem item = hashtagsMenu.add(Menu.NONE, entry.getKey(), Menu.NONE, entry.getValue().name); + item.setIcon(R.drawable.ic_fluent_number_symbol_24_regular); + UiUtils.insetPopupMenuIcon(ctx, item); + }); } public void updateToolbarLogo(){ @@ -472,10 +489,19 @@ public class HomeTabFragment extends MastodonToolbarFragment implements Scrollab && fabulous.isScrolling(); } + @Override + public void onConfigurationChanged(Configuration newConfig) { + super.onConfigurationChanged(newConfig); + if (elevationOnScrollListener != null) elevationOnScrollListener.setViews(getToolbar()); + } + private void updateSwitcherIcon(int i) { timelineIcon.setImageResource(timelines[i].getIcon().iconRes); timelineTitle.setText(timelines[i].getTitle(getContext())); showFab(); + if (elevationOnScrollListener != null && getCurrentFragment() instanceof IsOnTop f) { + elevationOnScrollListener.handleScroll(getContext(), f.isOnTop()); + } } @Override @@ -645,8 +671,8 @@ public class HomeTabFragment extends MastodonToolbarFragment implements Scrollab @Override protected void onShown() { super.onShown(); - Object pinnedTimelines = GlobalUserPreferences.pinnedTimelines.get(accountID); - if (pinnedTimelines != null && timelineDefinitions != pinnedTimelines) UiUtils.restartApp(); + Object timelines = AccountSessionManager.get(accountID).getLocalPreferences().timelines; + if (timelines != null && timelinesList!= timelines) UiUtils.restartApp(); } @Override @@ -710,6 +736,10 @@ public class HomeTabFragment extends MastodonToolbarFragment implements Scrollab return hashtagsItems.values(); } + public Fragment getCurrentFragment() { + return fragments[pager.getCurrentItem()]; + } + public ImageButton getFab() { return fab; } 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 6b60cbcf9..d511740fe 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/HomeTimelineFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/HomeTimelineFragment.java @@ -11,11 +11,13 @@ import androidx.recyclerview.widget.RecyclerView; import org.joinmastodon.android.GlobalUserPreferences; import org.joinmastodon.android.api.requests.markers.SaveMarkers; import org.joinmastodon.android.api.requests.timelines.GetHomeTimeline; +import org.joinmastodon.android.api.session.AccountLocalPreferences; import org.joinmastodon.android.api.session.AccountSessionManager; import org.joinmastodon.android.events.StatusCreatedEvent; import org.joinmastodon.android.model.CacheablePaginatedResponse; -import org.joinmastodon.android.model.Filter; +import org.joinmastodon.android.model.FilterContext; import org.joinmastodon.android.model.Status; +import org.joinmastodon.android.model.TimelineMarkers; import org.joinmastodon.android.ui.displayitems.GapStatusDisplayItem; import org.joinmastodon.android.ui.displayitems.StatusDisplayItem; import org.joinmastodon.android.utils.StatusFilterPredicate; @@ -49,8 +51,9 @@ public class HomeTimelineFragment extends StatusListFragment { } private boolean typeFilterPredicate(Status s) { - return (GlobalUserPreferences.showReplies || s.inReplyToId == null) && - (GlobalUserPreferences.showBoosts || s.reblog == null); + AccountLocalPreferences lp=getLocalPrefs(); + return (lp.showReplies || s.inReplyToId == null) && + (lp.showBoosts || s.reblog == null); } private List filterPosts(List items) { @@ -110,7 +113,7 @@ public class HomeTimelineFragment extends StatusListFragment { new SaveMarkers(topPostID, null) .setCallback(new Callback<>(){ @Override - public void onSuccess(SaveMarkers.Response result){ + public void onSuccess(TimelineMarkers result){ } @Override @@ -123,8 +126,8 @@ public class HomeTimelineFragment extends StatusListFragment { } } - public void onStatusCreated(StatusCreatedEvent ev){ - prependItems(Collections.singletonList(ev.status), true); + public void onStatusCreated(Status status){ + prependItems(Collections.singletonList(status), true); } private void loadNewPosts(){ @@ -134,7 +137,7 @@ public class HomeTimelineFragment extends StatusListFragment { // we'll get the currently topmost post as last in the response. This way we know there's no gap // between the existing and newly loaded parts of the timeline. String sinceID=data.size()>1 ? data.get(1).id : "1"; - currentRequest=new GetHomeTimeline(null, null, 20, sinceID) + currentRequest=new GetHomeTimeline(null, null, 20, sinceID, getLocalPrefs().timelineReplyVisibility) .setCallback(new Callback<>(){ @Override public void onSuccess(List result){ @@ -151,8 +154,7 @@ public class HomeTimelineFragment extends StatusListFragment { result.get(result.size()-1).hasGapAfter=true; toAdd=result; } - StatusFilterPredicate filterPredicate=new StatusFilterPredicate(accountID, getFilterContext()); - toAdd=toAdd.stream().filter(filterPredicate).collect(Collectors.toList()); + AccountSessionManager.get(accountID).filterStatuses(toAdd, getFilterContext()); if(!toAdd.isEmpty()){ prependItems(toAdd, true); if (parent != null && GlobalUserPreferences.showNewPostsButton) parent.showNewPostsButton(); @@ -169,7 +171,7 @@ public class HomeTimelineFragment extends StatusListFragment { .exec(accountID); if (parent.getParentFragment() instanceof HomeFragment homeFragment) { - homeFragment.updateNotificationBadge(); + homeFragment.reloadNotificationsForUnreadCount(); } } @@ -182,7 +184,7 @@ public class HomeTimelineFragment extends StatusListFragment { V.setVisibilityAnimated(item.text, View.GONE); GapStatusDisplayItem gap=item.getItem(); dataLoading=true; - currentRequest=new GetHomeTimeline(item.getItemID(), null, 20, null) + currentRequest=new GetHomeTimeline(item.getItemID(), null, 20, null, getLocalPrefs().timelineReplyVisibility) .setCallback(new Callback<>(){ @Override public void onSuccess(List result){ @@ -239,6 +241,7 @@ public class HomeTimelineFragment extends StatusListFragment { insertedPosts.add(s); } } + AccountSessionManager.get(accountID).filterStatuses(insertedPosts, getFilterContext()); if(targetList.isEmpty()){ // oops. We didn't add new posts, but at least we know there are none. adapter.notifyItemRemoved(getMainAdapterOffset()+gapPos); @@ -285,8 +288,8 @@ public class HomeTimelineFragment extends StatusListFragment { } @Override - protected Filter.FilterContext getFilterContext() { - return Filter.FilterContext.HOME; + protected FilterContext getFilterContext() { + return FilterContext.HOME; } @Override 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 bc931f919..aaa6e83f1 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/ListTimelineFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/ListTimelineFragment.java @@ -18,7 +18,7 @@ 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.FilterContext; import org.joinmastodon.android.model.ListTimeline; import org.joinmastodon.android.model.Status; import org.joinmastodon.android.model.TimelineDefinition; @@ -134,7 +134,7 @@ public class ListTimelineFragment extends PinnableStatusListFragment { @Override protected void doLoadData(int offset, int count) { - currentRequest=new GetListTimeline(listID, offset==0 ? null : getMaxID(), null, count, null) + currentRequest=new GetListTimeline(listID, offset==0 ? null : getMaxID(), null, count, null, getLocalPrefs().timelineReplyVisibility) .setCallback(new SimpleCallback<>(this) { @Override public void onSuccess(List result) { @@ -167,8 +167,8 @@ public class ListTimelineFragment extends PinnableStatusListFragment { @Override - protected Filter.FilterContext getFilterContext() { - return Filter.FilterContext.HOME; + protected FilterContext getFilterContext() { + return FilterContext.HOME; } @Override diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/ListsFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/ListsFragment.java index 6f28eb95a..15aba415a 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/ListsFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/ListsFragment.java @@ -42,7 +42,7 @@ import me.grishka.appkit.api.SimpleCallback; import me.grishka.appkit.utils.BindableViewHolder; import me.grishka.appkit.views.UsableRecyclerView; -public class ListsFragment extends RecyclerFragment implements ScrollableToTop, ProvidesAssistContent.ProvidesWebUri { +public class ListsFragment extends MastodonRecyclerFragment implements ScrollableToTop, ProvidesAssistContent.ProvidesWebUri { private String accountID; private String profileAccountId; private final HashMap userInListBefore = new HashMap<>(); @@ -80,7 +80,7 @@ public class ListsFragment extends RecyclerFragment implements Scr @Override public void onViewCreated(View view, Bundle savedInstanceState) { super.onViewCreated(view, savedInstanceState); - list.addItemDecoration(new DividerItemDecoration(getActivity(), R.attr.colorPollVoted, 0.5f, 56, 16)); + list.addItemDecoration(new DividerItemDecoration(getActivity(), R.attr.colorM3OutlineVariant, 0.5f, 56, 16)); } @Override diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/MastodonRecyclerFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/MastodonRecyclerFragment.java new file mode 100644 index 000000000..3d3781f36 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/MastodonRecyclerFragment.java @@ -0,0 +1,87 @@ +package org.joinmastodon.android.fragments; + +import android.os.Bundle; +import android.view.View; +import android.widget.Toolbar; + +import org.joinmastodon.android.R; +import org.joinmastodon.android.ui.utils.UiUtils; +import org.joinmastodon.android.utils.ElevationOnScrollListener; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +import androidx.annotation.CallSuper; +import androidx.swiperefreshlayout.widget.SwipeRefreshLayout; +import me.grishka.appkit.fragments.BaseRecyclerFragment; +import me.grishka.appkit.views.FragmentRootLinearLayout; + +public abstract class MastodonRecyclerFragment extends BaseRecyclerFragment{ + protected ElevationOnScrollListener elevationOnScrollListener; + + public MastodonRecyclerFragment(int perPage){ + super(perPage); + } + + public MastodonRecyclerFragment(int layout, int perPage){ + super(layout, perPage); + } + + protected List getViewsForElevationEffect(){ + Toolbar toolbar=getToolbar(); + return toolbar!=null ? Collections.singletonList(toolbar) : Collections.emptyList(); + } + + @Override + @CallSuper + public void onViewCreated(View view, Bundle savedInstanceState){ + super.onViewCreated(view, savedInstanceState); + if (getParentFragment() instanceof HasElevationOnScrollListener elevator) + list.addOnScrollListener(elevator.getElevationOnScrollListener()); + else if(wantsElevationOnScrollEffect()) + list.addOnScrollListener(elevationOnScrollListener=new ElevationOnScrollListener((FragmentRootLinearLayout) view, getViewsForElevationEffect())); + if(refreshLayout!=null) + setRefreshLayoutColors(refreshLayout); + } + + @Override + @CallSuper + protected void onUpdateToolbar(){ + super.onUpdateToolbar(); + if(elevationOnScrollListener!=null){ + elevationOnScrollListener.setViews(getViewsForElevationEffect()); + } + } + + protected boolean wantsElevationOnScrollEffect(){ + return true; + } + + public List getData() { + return data; + } + + public static void setRefreshLayoutColors(SwipeRefreshLayout l) { + List colors = new ArrayList<>(Arrays.asList( + UiUtils.isDarkTheme() ? R.color.primary_200 : R.color.primary_600, + UiUtils.isDarkTheme() ? R.color.red_primary_200 : R.color.red_primary_600, + UiUtils.isDarkTheme() ? R.color.green_primary_200 : R.color.green_primary_600, + UiUtils.isDarkTheme() ? R.color.blue_primary_200 : R.color.blue_primary_600, + UiUtils.isDarkTheme() ? R.color.purple_200 : R.color.purple_600 + )); + int primary = UiUtils.getThemeColorRes(l.getContext(), + UiUtils.isDarkTheme() ? R.attr.colorPrimary200 : R.attr.colorPrimary600); + if (!colors.contains(primary)) colors.add(0, primary); + int offset = colors.indexOf(primary); + int[] sorted = new int[colors.size()]; + for (int i = 0; i < colors.size(); i++) { + sorted[i] = colors.get((i + offset) % colors.size()); + } + l.setColorSchemeResources(sorted); + int colorBackground=UiUtils.getThemeColor(l.getContext(), R.attr.colorM3Background); + int colorPrimary=UiUtils.getThemeColor(l.getContext(), R.attr.colorM3Primary); + l.setProgressBackgroundColorSchemeColor(UiUtils.alphaBlendColors(colorBackground, colorPrimary, 0.11f)); + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/MastodonToolbarFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/MastodonToolbarFragment.java index f690f1a39..cb61c0b9d 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/MastodonToolbarFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/MastodonToolbarFragment.java @@ -11,6 +11,15 @@ import androidx.annotation.CallSuper; import me.grishka.appkit.fragments.ToolbarFragment; public abstract class MastodonToolbarFragment extends ToolbarFragment{ + + public MastodonToolbarFragment(){ + super(); + } + + protected MastodonToolbarFragment(int layout){ + super(layout); + } + @Override public void onViewCreated(View view, Bundle savedInstanceState){ super.onViewCreated(view, savedInstanceState); 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 6a3ce20d1..b40565d1d 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/NotificationsFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/NotificationsFragment.java @@ -3,6 +3,7 @@ package org.joinmastodon.android.fragments; import android.app.Activity; import android.app.Fragment; import android.app.assist.AssistContent; +import android.content.res.Configuration; import android.os.Build; import android.os.Bundle; import android.view.LayoutInflater; @@ -24,6 +25,9 @@ import org.joinmastodon.android.E; import org.joinmastodon.android.GlobalUserPreferences; import org.joinmastodon.android.R; import org.joinmastodon.android.api.requests.accounts.GetFollowRequests; +import org.joinmastodon.android.api.requests.markers.SaveMarkers; +import org.joinmastodon.android.api.requests.notifications.PleromaMarkNotificationsRead; +import org.joinmastodon.android.api.session.AccountSessionManager; import org.joinmastodon.android.events.FollowRequestHandledEvent; import org.joinmastodon.android.model.Account; import org.joinmastodon.android.model.HeaderPaginationList; @@ -31,6 +35,8 @@ import org.joinmastodon.android.ui.SimpleViewHolder; import org.joinmastodon.android.ui.tabs.TabLayout; import org.joinmastodon.android.ui.tabs.TabLayoutMediator; import org.joinmastodon.android.ui.utils.UiUtils; +import org.joinmastodon.android.utils.ElevationOnScrollListener; +import org.joinmastodon.android.utils.ObjectIdComparator; import org.joinmastodon.android.utils.ProvidesAssistContent; import me.grishka.appkit.Nav; @@ -38,15 +44,19 @@ import me.grishka.appkit.api.Callback; import me.grishka.appkit.api.ErrorResponse; import me.grishka.appkit.fragments.BaseRecyclerFragment; import me.grishka.appkit.utils.V; +import me.grishka.appkit.views.FragmentRootLinearLayout; -public class NotificationsFragment extends MastodonToolbarFragment implements ScrollableToTop, ProvidesAssistContent { +public class NotificationsFragment extends MastodonToolbarFragment implements ScrollableToTop, ProvidesAssistContent, HasElevationOnScrollListener { - private TabLayout tabLayout; + TabLayout tabLayout; private ViewPager2 pager; private FrameLayout[] tabViews; + private View tabsDivider; private TabLayoutMediator tabLayoutMediator; - - private NotificationsListFragment allNotificationsFragment, mentionsFragment, postsFragment; + String unreadMarker, realUnreadMarker; + private MenuItem markAllReadItem; + private NotificationsListFragment allNotificationsFragment, mentionsFragment; + private ElevationOnScrollListener elevationOnScrollListener; private String accountID; @Override @@ -72,11 +82,19 @@ public class NotificationsFragment extends MastodonToolbarFragment implements Sc setTitle(R.string.notifications); } + @Override + public void onShown() { + super.onShown(); + unreadMarker=realUnreadMarker=AccountSessionManager.get(accountID).getLastKnownNotificationsMarker(); + } + @Override public void onCreateOptionsMenu(Menu menu, MenuInflater inflater){ inflater.inflate(R.menu.notifications, menu); menu.findItem(R.id.clear_notifications).setVisible(GlobalUserPreferences.enableDeleteNotifications); - UiUtils.enableOptionsMenuIcons(getActivity(), menu, R.id.follow_requests); + markAllReadItem=menu.findItem(R.id.mark_all_read); + updateMarkAllReadButton(); + UiUtils.enableOptionsMenuIcons(getActivity(), menu, R.id.follow_requests, R.id.mark_all_read); } @Override @@ -93,25 +111,49 @@ public class NotificationsFragment extends MastodonToolbarFragment implements Sc } }); return true; + } else if (item.getItemId() == R.id.mark_all_read) { + markAsRead(); + if (getCurrentFragment() instanceof NotificationsListFragment nlf) { + nlf.resetUnreadBackground(); + } + return true; } return false; } + void markAsRead(){ + if(allNotificationsFragment.getData().isEmpty()) return; + String id=allNotificationsFragment.getData().get(0).id; + if(ObjectIdComparator.INSTANCE.compare(id, realUnreadMarker)>0){ + new SaveMarkers(null, id).exec(accountID); + if (allNotificationsFragment.isInstanceAkkoma()) { + new PleromaMarkNotificationsRead(id).exec(accountID); + } + AccountSessionManager.get(accountID).setNotificationsMarker(id, true); + realUnreadMarker=id; + updateMarkAllReadButton(); + } + } + + public void updateMarkAllReadButton(){ + markAllReadItem.setVisible(!allNotificationsFragment.getData().isEmpty() && realUnreadMarker!=null && !realUnreadMarker.equals(allNotificationsFragment.getData().get(0).id)); + } + @Override public View onCreateContentView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState){ LinearLayout view=(LinearLayout) inflater.inflate(R.layout.fragment_notifications, container, false); tabLayout=view.findViewById(R.id.tabbar); + tabsDivider=view.findViewById(R.id.tabs_divider); 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,7 +162,7 @@ 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.setTabTextColors(UiUtils.getThemeColor(getActivity(), R.attr.colorM3OnSurfaceVariant), UiUtils.getThemeColor(getActivity(), R.attr.colorM3Primary)); tabLayout.addOnTabSelectedListener(new TabLayout.OnTabSelectedListener() { @Override public void onTabSelected(TabLayout.Tab tab) {} @@ -140,6 +182,8 @@ public class NotificationsFragment extends MastodonToolbarFragment implements Sc pager.registerOnPageChangeCallback(new ViewPager2.OnPageChangeCallback(){ @Override public void onPageSelected(int position){ + if (elevationOnScrollListener != null && getCurrentFragment() instanceof IsOnTop f) + elevationOnScrollListener.handleScroll(getContext(), f.isOnTop()); if(position==0) return; Fragment _page=getFragmentForPage(position); @@ -163,15 +207,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(); } @@ -181,10 +219,8 @@ 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); } }); tabLayoutMediator.attach(); @@ -192,6 +228,28 @@ public class NotificationsFragment extends MastodonToolbarFragment implements Sc return view; } + @Override + public void onViewCreated(View view, Bundle savedInstanceState) { + super.onViewCreated(view, savedInstanceState); + elevationOnScrollListener = new ElevationOnScrollListener((FragmentRootLinearLayout) view, getToolbar(), tabLayout); + elevationOnScrollListener.setDivider(tabsDivider); + } + + @Override + public void onConfigurationChanged(Configuration newConfig) { + super.onConfigurationChanged(newConfig); + if (elevationOnScrollListener == null) return; + elevationOnScrollListener.setViews(getToolbar(), tabLayout); + if (getCurrentFragment() instanceof IsOnTop f) { + elevationOnScrollListener.handleScroll(getContext(), f.isOnTop()); + } + } + + @Override + public ElevationOnScrollListener getElevationOnScrollListener() { + return elevationOnScrollListener; + } + public void refreshFollowRequestsBadge() { new GetFollowRequests(null, 1).setCallback(new Callback<>() { @Override @@ -212,11 +270,6 @@ public class NotificationsFragment extends MastodonToolbarFragment implements Sc @Override public void scrollToTop(){ - if (getFragmentForPage(pager.getCurrentItem()).isOnTop() && GlobalUserPreferences.doubleTapToSwipe) { - int nextPage = (pager.getCurrentItem() + 1) % tabViews.length; - pager.setCurrentItem(nextPage, true); - return; - } getFragmentForPage(pager.getCurrentItem()).scrollToTop(); } @@ -237,11 +290,14 @@ public class NotificationsFragment extends MastodonToolbarFragment implements Sc return switch(page){ case 0 -> allNotificationsFragment; case 1 -> mentionsFragment; - case 2 -> postsFragment; default -> throw new IllegalStateException("Unexpected value: "+page); }; } + public Fragment getCurrentFragment() { + return getFragmentForPage(pager.getCurrentItem()); + } + @Override public void onProvideAssistContent(AssistContent assistContent) { callFragmentToProvideAssistContent(getFragmentForPage(pager.getCurrentItem()), assistContent); @@ -263,7 +319,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 a8dfc1c39..33cba9f52 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/NotificationsListFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/NotificationsListFragment.java @@ -1,6 +1,9 @@ package org.joinmastodon.android.fragments; import android.app.Activity; +import android.graphics.Canvas; +import android.graphics.Paint; +import android.graphics.Rect; import android.net.Uri; import android.os.Bundle; import android.text.TextUtils; @@ -9,51 +12,46 @@ import android.view.View; import com.squareup.otto.Subscribe; import org.joinmastodon.android.E; +import org.joinmastodon.android.GlobalUserPreferences; import org.joinmastodon.android.R; -import org.joinmastodon.android.api.requests.markers.SaveMarkers; -import org.joinmastodon.android.api.requests.notifications.PleromaMarkNotificationsRead; import org.joinmastodon.android.api.session.AccountSessionManager; -import org.joinmastodon.android.events.AllNotificationsSeenEvent; import org.joinmastodon.android.events.PollUpdatedEvent; import org.joinmastodon.android.events.RemoveAccountPostsEvent; import org.joinmastodon.android.events.StatusCountersUpdatedEvent; -import org.joinmastodon.android.model.Account; -import org.joinmastodon.android.model.CacheablePaginatedResponse; -import org.joinmastodon.android.model.Emoji; -import org.joinmastodon.android.model.Filter; -import org.joinmastodon.android.model.Instance; -import org.joinmastodon.android.model.Markers; 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.EmojiReactionsStatusDisplayItem; import org.joinmastodon.android.ui.displayitems.ExtendedFooterStatusDisplayItem; import org.joinmastodon.android.ui.displayitems.FooterStatusDisplayItem; -import org.joinmastodon.android.ui.displayitems.HeaderStatusDisplayItem; +import org.joinmastodon.android.ui.displayitems.NotificationHeaderStatusDisplayItem; import org.joinmastodon.android.ui.displayitems.StatusDisplayItem; -import org.joinmastodon.android.ui.displayitems.TextStatusDisplayItem; -import org.joinmastodon.android.ui.text.HtmlParser; import org.joinmastodon.android.ui.utils.DiscoverInfoBannerHelper; import org.joinmastodon.android.ui.utils.InsetStatusItemDecoration; import org.joinmastodon.android.ui.utils.UiUtils; +import org.joinmastodon.android.utils.ElevationOnScrollListener; +import org.joinmastodon.android.utils.ObjectIdComparator; import org.parceler.Parcels; import java.util.ArrayList; -import java.util.Arrays; import java.util.Collections; import java.util.List; -import java.util.Set; import java.util.stream.Collectors; import java.util.stream.Stream; +import androidx.annotation.NonNull; import androidx.recyclerview.widget.RecyclerView; -import me.grishka.appkit.Nav; import me.grishka.appkit.api.SimpleCallback; +import me.grishka.appkit.utils.MergeRecyclerAdapter; +import me.grishka.appkit.views.FragmentRootLinearLayout; -public class NotificationsListFragment extends BaseStatusListFragment{ +public class NotificationsListFragment extends BaseStatusListFragment { private boolean onlyMentions; private boolean onlyPosts; private String maxID; - private final DiscoverInfoBannerHelper bannerHelper = new DiscoverInfoBannerHelper(DiscoverInfoBannerHelper.BannerType.POST_NOTIFICATIONS); + private boolean reloadingFromCache; + private DiscoverInfoBannerHelper bannerHelper; @Override protected boolean wantsComposeButton() { @@ -64,6 +62,13 @@ public class NotificationsListFragment extends BaseStatusListFragment buildDisplayItems(Notification n){ - Account reportTarget = n.report == null ? null : n.report.targetAccount == null ? null : - n.report.targetAccount; - Emoji emoji = new Emoji(); - if(n.emojiUrl!=null){ - emoji.shortcode=n.emoji.substring(1,n.emoji.length()-1); - emoji.url=n.emojiUrl; - emoji.staticUrl=n.emojiUrl; - emoji.visibleInPicker=false; + NotificationHeaderStatusDisplayItem titleItem; + if(n.type==Notification.Type.MENTION || n.type==Notification.Type.STATUS){ + titleItem=null; + }else{ + titleItem=new NotificationHeaderStatusDisplayItem(n.id, this, n, accountID); + } + if (n.type == Notification.Type.FOLLOW_REQUEST) { + ArrayList items = new ArrayList<>(); + items.add(titleItem); + items.add(new AccountCardStatusDisplayItem(n.id, this, n.account, n)); + return items; } - String extraText=switch(n.type){ - case FOLLOW -> getString(R.string.user_followed_you); - case FOLLOW_REQUEST -> getString(R.string.user_sent_follow_request); - case MENTION, STATUS -> null; - case REBLOG -> 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); - case REACTION, PLEROMA_EMOJI_REACTION -> - n.emoji != null ? getString(R.string.sk_reacted_with, n.emoji) : getString(R.string.sk_reacted); - }; - HeaderStatusDisplayItem titleItem=extraText!=null ? new HeaderStatusDisplayItem(n.id, n.account, n.createdAt, this, accountID, n.status, n.emojiUrl!=null ? HtmlParser.parseCustomEmoji(extraText, Collections.singletonList(emoji)) : extraText, n, null) : null; if(n.status!=null){ - ArrayList items=StatusDisplayItem.buildItems(this, n.status, accountID, n, knownAccounts, titleItem!=null, titleItem==null, n, false, Filter.FilterContext.NOTIFICATIONS); + int flags=titleItem==null ? 0 : (StatusDisplayItem.FLAG_NO_FOOTER | StatusDisplayItem.FLAG_INSET | StatusDisplayItem.FLAG_NO_EMOJI_REACTIONS); // | StatusDisplayItem.FLAG_NO_HEADER); + if (GlobalUserPreferences.spectatorMode) + flags |= StatusDisplayItem.FLAG_NO_FOOTER; + if (!getLocalPrefs().showEmojiReactionsInLists) + flags |= StatusDisplayItem.FLAG_NO_EMOJI_REACTIONS; + ArrayList items=StatusDisplayItem.buildItems(this, n.status, accountID, n, knownAccounts, null, flags); if(titleItem!=null) items.add(0, titleItem); return items; }else if(titleItem!=null){ - AccountCardStatusDisplayItem card=new AccountCardStatusDisplayItem(n.id, this, - reportTarget != null ? reportTarget : n.account, n); - TextStatusDisplayItem text = n.report != null && !TextUtils.isEmpty(n.report.comment) ? - new TextStatusDisplayItem(n.id, n.report.comment, this, - Status.ofFake(n.id, n.report.comment, n.createdAt), true) : - null; - return text == null ? Arrays.asList(titleItem, card) : Arrays.asList(titleItem, text, card); + return Collections.singletonList(titleItem); }else{ return Collections.emptyList(); } } - @Override protected void addAccountToKnown(Notification s){ if(!knownAccounts.containsKey(s.account.id)) @@ -144,52 +129,38 @@ public class NotificationsListFragment extends BaseStatusListFragment0 ? maxID : null, count, onlyMentions, onlyPosts, refreshing, new SimpleCallback<>(this){ + .getNotifications(offset>0 ? maxID : null, count, onlyMentions, onlyPosts, refreshing && !reloadingFromCache, new SimpleCallback<>(this){ @Override - public void onSuccess(CacheablePaginatedResponse> result){ - if (getActivity() == null) return; - if(refreshing) - relationships.clear(); + public void onSuccess(PaginatedResponse> result){ + if(getActivity()==null) + return; maxID=result.maxID; onDataLoaded(result.items.stream().filter(n->n.type!=null).collect(Collectors.toList()), !result.items.isEmpty()); - Set needRelationships=result.items.stream() - .filter(ntf->ntf.status==null && !relationships.containsKey(ntf.account.id)) - .map(ntf->ntf.account.id) - .collect(Collectors.toSet()); - loadRelationships(needRelationships); - - Markers markers = AccountSessionManager.getInstance().getAccount(accountID).markers; - if(offset==0 && !result.items.isEmpty() && !result.isFromCache() && markers != null && markers.notifications != null){ - E.post(new AllNotificationsSeenEvent()); - new SaveMarkers(null, result.items.get(0).id).exec(accountID); - AccountSessionManager.getInstance().getAccount(accountID).markers - .notifications.lastReadId = result.items.get(0).id; - AccountSessionManager.getInstance().writeAccountsFile(); - - if (isInstanceAkkoma()) { - new PleromaMarkNotificationsRead(result.items.get(0).id).exec(accountID); - } + reloadingFromCache=false; + if (getParentFragment() instanceof NotificationsFragment nf) { + nf.updateMarkAllReadButton(); } } }); } @Override - protected void onRelationshipsLoaded(){ - if(getActivity()==null) - return; - for(int i=0;i holder){ + String itemID=holder.getItemID(); + if(ObjectIdComparator.INSTANCE.compare(itemID, nf.unreadMarker)>0){ + parent.getDecoratedBoundsWithMargins(child, tmpRect); + c.drawRect(tmpRect, paint); + } + } + } + } + } + }, 0); + } + + @Override + protected List getViewsForElevationEffect(){ + if (getParentFragment() instanceof NotificationsFragment nf) { + ArrayList views=new ArrayList<>(super.getViewsForElevationEffect()); + views.add(nf.tabLayout); + return views; + } else { + return super.getViewsForElevationEffect(); + } + } + + @Override + public void onSaveInstanceState(Bundle outState){ + super.onSaveInstanceState(outState); + outState.putBoolean("onlyMentions", onlyMentions); + outState.putBoolean("onlyPosts", onlyPosts); } private Notification getNotificationByID(String id){ @@ -238,12 +252,15 @@ public class NotificationsListFragment extends BaseStatusListFragment=adapter.getItemCount()); + } + + void resetUnreadBackground(){ + if (getParentFragment() instanceof NotificationsFragment nf) { + nf.unreadMarker=nf.realUnreadMarker; + list.invalidate(); + } + } + + @Override + public void onRefresh(){ + super.onRefresh(); + if (getParentFragment() instanceof NotificationsFragment nf) { + if (!onlyMentions && !onlyPosts) nf.markAsRead(); + else AccountSessionManager.get(accountID).reloadNotificationsMarker(m->{ + nf.unreadMarker=nf.realUnreadMarker=m; + nf.updateMarkAllReadButton(); + }); + } + resetUnreadBackground(); + } + + @Override + protected RecyclerView.Adapter getAdapter(){ + if (bannerHelper == null) return super.getAdapter(); + MergeRecyclerAdapter adapter=new MergeRecyclerAdapter(); + bannerHelper.maybeAddBanner(list, adapter); + adapter.addAdapter(super.getAdapter()); + return adapter; + } + @Override public Uri getWebUri(Uri.Builder base) { return base.path(isInstanceAkkoma() diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/PinnableStatusListFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/PinnableStatusListFragment.java index 29ba37748..d1c6fcfbf 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/PinnableStatusListFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/PinnableStatusListFragment.java @@ -7,21 +7,21 @@ import android.view.MenuInflater; import android.view.MenuItem; import android.widget.Toast; -import org.joinmastodon.android.GlobalUserPreferences; import org.joinmastodon.android.R; +import org.joinmastodon.android.api.session.AccountLocalPreferences; +import org.joinmastodon.android.api.session.AccountSessionManager; import org.joinmastodon.android.model.TimelineDefinition; import java.util.ArrayList; import java.util.List; -public abstract class PinnableStatusListFragment extends StatusListFragment implements DomainDisplay { - protected boolean pinnedUpdated; - protected List pinnedTimelines; +public abstract class PinnableStatusListFragment extends StatusListFragment { + protected List timelines; @Override public void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); - pinnedTimelines = new ArrayList<>(GlobalUserPreferences.pinnedTimelines.getOrDefault(accountID, TimelineDefinition.getDefaultTimelines(accountID))); + timelines=new ArrayList<>(AccountSessionManager.get(accountID).getLocalPreferences().timelines); } @Override @@ -31,7 +31,7 @@ public abstract class PinnableStatusListFragment extends StatusListFragment impl } protected boolean isPinned() { - return pinnedTimelines.contains(makeTimelineDefinition()); + return timelines.contains(makeTimelineDefinition()); } protected void updatePinButton(MenuItem pin) { @@ -54,29 +54,18 @@ public abstract class PinnableStatusListFragment extends StatusListFragment impl } protected void togglePin(MenuItem pin) { - pinnedUpdated = true; + onPinnedUpdated(true); getToolbar().performHapticFeedback(HapticFeedbackConstants.CONTEXT_CLICK); TimelineDefinition def = makeTimelineDefinition(); boolean pinned = isPinned(); - if (pinned) pinnedTimelines.remove(def); - else pinnedTimelines.add(def); + if (pinned) timelines.remove(def); + else timelines.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(); + AccountLocalPreferences prefs=AccountSessionManager.get(accountID).getLocalPreferences(); + prefs.timelines=new ArrayList<>(timelines); + prefs.save(); updatePinButton(pin); } - protected Bundle getResultArgs() { - return new Bundle(); - } - - @Override - public void onDestroy() { - super.onDestroy(); - Bundle resultArgs = getResultArgs(); - if (pinnedUpdated) { - resultArgs.putBoolean("pinnedUpdated", true); - setResult(true, resultArgs); - } - } + public void onPinnedUpdated(boolean pinned) {} } diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/PinnedPostsListFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/PinnedPostsListFragment.java index a091e5d42..53c44d36d 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/PinnedPostsListFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/PinnedPostsListFragment.java @@ -6,7 +6,7 @@ import android.os.Bundle; import org.joinmastodon.android.R; import org.joinmastodon.android.api.requests.accounts.GetAccountStatuses; import org.joinmastodon.android.model.Account; -import org.joinmastodon.android.model.Filter; +import org.joinmastodon.android.model.FilterContext; import org.joinmastodon.android.model.Status; import org.parceler.Parcels; @@ -41,8 +41,8 @@ public class PinnedPostsListFragment extends StatusListFragment{ } @Override - protected Filter.FilterContext getFilterContext() { - return Filter.FilterContext.ACCOUNT; + protected FilterContext getFilterContext() { + return FilterContext.ACCOUNT; } @Override diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/ProfileAboutFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/ProfileAboutFragment.java index e6275e921..e27369032 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/ProfileAboutFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/ProfileAboutFragment.java @@ -1,6 +1,5 @@ package org.joinmastodon.android.fragments; -import android.app.Activity; import android.app.Fragment; import android.graphics.Canvas; import android.graphics.Paint; @@ -13,23 +12,12 @@ import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; import android.view.WindowInsets; -import android.view.inputmethod.InputMethodManager; import android.widget.EditText; -import android.widget.FrameLayout; -import android.widget.ImageButton; +import android.widget.ImageView; import android.widget.TextView; -import android.widget.Toast; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.recyclerview.widget.ItemTouchHelper; -import androidx.recyclerview.widget.LinearLayoutManager; -import androidx.recyclerview.widget.RecyclerView; import org.joinmastodon.android.R; -import org.joinmastodon.android.api.requests.accounts.SetPrivateNote; import org.joinmastodon.android.model.AccountField; -import org.joinmastodon.android.model.Relationship; import org.joinmastodon.android.ui.BetterItemAnimator; import org.joinmastodon.android.ui.text.CustomEmojiSpan; import org.joinmastodon.android.ui.utils.SimpleTextWatcher; @@ -39,8 +27,11 @@ import org.joinmastodon.android.ui.views.LinkedTextView; import java.util.Collections; import java.util.List; -import me.grishka.appkit.api.Callback; -import me.grishka.appkit.api.ErrorResponse; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.recyclerview.widget.ItemTouchHelper; +import androidx.recyclerview.widget.LinearLayoutManager; +import androidx.recyclerview.widget.RecyclerView; import me.grishka.appkit.fragments.WindowInsetsAwareFragment; import me.grishka.appkit.imageloader.ImageLoaderRecyclerAdapter; import me.grishka.appkit.imageloader.ImageLoaderViewHolder; @@ -53,21 +44,15 @@ import me.grishka.appkit.utils.V; import me.grishka.appkit.views.UsableRecyclerView; public class ProfileAboutFragment extends Fragment implements WindowInsetsAwareFragment{ - private static final int MAX_FIELDS=4; + static final int MAX_FIELDS=Integer.MAX_VALUE; public UsableRecyclerView list; - public FrameLayout noteWrap; - public EditText noteEdit; - private String accountID; - private String profileAccountID; - private String note; private List fields=Collections.emptyList(); private AboutAdapter adapter; - private Paint dividerPaint=new Paint(); private boolean isInEditMode; private ItemTouchHelper dragHelper=new ItemTouchHelper(new ReorderCallback()); - private RecyclerView.ViewHolder draggedViewHolder; private ListImageLoaderWrapper imgLoader; + private boolean editDirty; public void setFields(List fields){ this.fields=fields; @@ -79,101 +64,37 @@ public class ProfileAboutFragment extends Fragment implements WindowInsetsAwareF adapter.notifyDataSetChanged(); } - public void setNote(String note, String accountID, String profileAccountID){ - this.note=note; - this.accountID=accountID; - this.profileAccountID=profileAccountID; -// noteWrap.setVisibility(View.VISIBLE); -// noteEdit.setVisibility(View.VISIBLE); -// noteEdit.setText(note); - } - @Nullable @Override public View onCreateView(LayoutInflater inflater, @Nullable ViewGroup container, Bundle savedInstanceState){ - View view = inflater.inflate(R.layout.fragment_profile_about, null); - - noteEdit = view.findViewById(R.id.note_edit); - noteWrap = view.findViewById(R.id.note_edit_wrap); - ImageButton noteEditConfirm = view.findViewById(R.id.note_edit_confirm); - - - noteEdit.setOnFocusChangeListener((v, hasFocus) -> { - if (hasFocus) { - noteEditConfirm.setVisibility(View.VISIBLE); - noteEditConfirm.animate() - .alpha(1.0f) - .setDuration(700); - } else { - noteEditConfirm.animate() - .alpha(0.0f) - .setDuration(700); - noteEditConfirm.setVisibility(View.INVISIBLE); - } - }); - - noteEditConfirm.setOnClickListener((v -> { - if (!noteEdit.getText().toString().trim().equals(note)) { - savePrivateNote(); - } - InputMethodManager imm = (InputMethodManager) getContext().getSystemService(Activity.INPUT_METHOD_SERVICE); - imm.hideSoftInputFromWindow(this.getView().getRootView().getWindowToken(), 0); - noteEdit.clearFocus(); - })); - - list = view.findViewById(R.id.list); + list=new UsableRecyclerView(getActivity()); + list.setId(R.id.list); list.setItemAnimator(new BetterItemAnimator()); list.setDrawSelectorOnTop(true); list.setLayoutManager(new LinearLayoutManager(getActivity())); imgLoader=new ListImageLoaderWrapper(getActivity(), list, new RecyclerViewDelegate(list), null); list.setAdapter(adapter=new AboutAdapter()); - int pad=V.dp(16); - list.setPadding(pad, pad, pad, pad); + list.setPadding(0, V.dp(16), 0, 0); list.setClipToPadding(false); - dividerPaint.setStyle(Paint.Style.STROKE); - dividerPaint.setStrokeWidth(V.dp(1)); - dividerPaint.setColor(UiUtils.getThemeColor(getActivity(), R.attr.colorPollVoted)); - list.addItemDecoration(new RecyclerView.ItemDecoration(){ - @Override - public void onDrawOver(@NonNull Canvas c, @NonNull RecyclerView parent, @NonNull RecyclerView.State state){ - for(int i=0;i() { - @Override - public void onSuccess(Relationship result) {} - - @Override - public void onError(ErrorResponse result) { - Toast.makeText(getActivity(), getString(R.string.mo_personal_note_update_failed), Toast.LENGTH_LONG).show(); - } - }).exec(accountID); - } - public void enterEditMode(List editableFields){ isInEditMode=true; fields=editableFields; adapter.notifyDataSetChanged(); dragHelper.attachToRecyclerView(list); + editDirty=false; } public List getFields(){ return fields; } + public boolean isEditDirty(){ + return editDirty; + } + @Override public void onApplyWindowInsets(WindowInsets insets){ if(Build.VERSION.SDK_INT>=29 && insets.getTappableElementInsets().bottom==0){ @@ -248,36 +169,25 @@ public class ProfileAboutFragment extends Fragment implements WindowInsetsAwareF } private abstract class BaseViewHolder extends BindableViewHolder{ - protected ShapeDrawable background=new ShapeDrawable(); - public BaseViewHolder(int layout){ super(getActivity(), layout, list); - background.getPaint().setColor(UiUtils.getThemeColor(getActivity(), R.attr.colorBackgroundLight)); - itemView.setBackground(background); } @Override public void onBind(AccountField item){ - boolean first=getAbsoluteAdapterPosition()==0, last=getAbsoluteAdapterPosition()==adapter.getItemCount()-1; - float radius=V.dp(10); - float[] rad=new float[8]; - if(first) - rad[0]=rad[1]=rad[2]=rad[3]=radius; - if(last) - rad[4]=rad[5]=rad[6]=rad[7]=radius; - background.setShape(new RoundRectShape(rad, null, null)); - itemView.invalidateOutline(); } } private class AboutViewHolder extends BaseViewHolder implements ImageLoaderViewHolder{ - private TextView title; - private LinkedTextView value; + private final TextView title; + private final LinkedTextView value; +// private final ImageView verifiedIcon; public AboutViewHolder(){ super(R.layout.item_profile_about); title=findViewById(R.id.title); value=findViewById(R.id.value); +// verifiedIcon=findViewById(R.id.verified_icon); } @Override @@ -285,20 +195,7 @@ public class ProfileAboutFragment extends Fragment implements WindowInsetsAwareF super.onBind(item); title.setText(item.parsedName); value.setText(item.parsedValue); - if(item.verifiedAt!=null){ - background.getPaint().setColor(UiUtils.isDarkTheme() ? 0xFF49595a : 0xFFd7e3da); - 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{ - background.getPaint().setColor(UiUtils.getThemeColor(getActivity(), R.attr.colorBackgroundLight)); - value.setTextColor(UiUtils.getThemeColor(getActivity(), android.R.attr.textColorPrimary)); - value.setLinkTextColor(UiUtils.getThemeColor(getActivity(), android.R.attr.colorAccent)); - value.setCompoundDrawables(null, null, null, null); - } +// verifiedIcon.setVisibility(item.verifiedAt!=null ? View.VISIBLE : View.GONE); } @Override @@ -316,27 +213,38 @@ public class ProfileAboutFragment extends Fragment implements WindowInsetsAwareF } private class EditableAboutViewHolder extends BaseViewHolder{ - private EditText title; - private EditText value; + private final EditText title; + private final EditText value; + private boolean ignoreTextChange; public EditableAboutViewHolder(){ - super(R.layout.item_profile_about_editable); + super(R.layout.onboarding_profile_field); title=findViewById(R.id.title); - value=findViewById(R.id.value); + value=findViewById(R.id.content); 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); + title.addTextChangedListener(new SimpleTextWatcher(e->{ + item.name=e.toString(); + if(!ignoreTextChange) + editDirty=true; + })); + value.addTextChangedListener(new SimpleTextWatcher(e->{ + item.value=e.toString(); + if(!ignoreTextChange) + editDirty=true; + })); + findViewById(R.id.delete).setOnClickListener(this::onRemoveRowClick); } @Override public void onBind(AccountField item){ super.onBind(item); + ignoreTextChange=true; title.setText(item.name); value.setText(item.value); + ignoreTextChange=false; } private void onRemoveRowClick(View v){ @@ -388,8 +296,8 @@ public class ProfileAboutFragment extends Fragment implements WindowInsetsAwareF } } adapter.notifyItemMoved(fromPosition, toPosition); - ((BindableViewHolder)viewHolder).rebind(); - ((BindableViewHolder)target).rebind(); + ((BindableViewHolder)viewHolder).rebind(); + ((BindableViewHolder)target).rebind(); return true; } @@ -404,7 +312,6 @@ public class ProfileAboutFragment extends Fragment implements WindowInsetsAwareF if(actionState==ItemTouchHelper.ACTION_STATE_DRAG){ viewHolder.itemView.setTag(me.grishka.appkit.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; } } @@ -412,7 +319,6 @@ public class ProfileAboutFragment extends Fragment implements WindowInsetsAwareF 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 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 787efd74b..8ee1c4052 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/ProfileFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/ProfileFragment.java @@ -9,17 +9,23 @@ import android.app.Fragment; import android.app.assist.AssistContent; import android.content.Intent; import android.content.res.Configuration; +import android.content.res.TypedArray; 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.graphics.drawable.LayerDrawable; import android.net.Uri; import android.os.Build; import android.os.Bundle; import android.text.SpannableStringBuilder; import android.text.TextUtils; import android.text.style.ImageSpan; +import android.transition.ChangeBounds; +import android.transition.Fade; +import android.transition.TransitionManager; +import android.transition.TransitionSet; import android.util.Log; import android.view.Gravity; import android.view.LayoutInflater; @@ -39,8 +45,8 @@ 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; @@ -54,7 +60,6 @@ import org.joinmastodon.android.DomainManager; import org.joinmastodon.android.GlobalUserPreferences; import org.joinmastodon.android.MainActivity; import org.joinmastodon.android.R; -import org.joinmastodon.android.api.MastodonErrorResponse; import org.joinmastodon.android.api.requests.accounts.GetAccountByID; import org.joinmastodon.android.api.requests.accounts.GetAccountRelationships; import org.joinmastodon.android.api.requests.accounts.GetAccountStatuses; @@ -62,18 +67,22 @@ import org.joinmastodon.android.api.requests.accounts.GetOwnAccount; import org.joinmastodon.android.api.requests.accounts.SetAccountFollowed; import org.joinmastodon.android.api.requests.accounts.SetPrivateNote; import org.joinmastodon.android.api.requests.accounts.UpdateAccountCredentials; +import org.joinmastodon.android.api.requests.instance.GetInstance; import org.joinmastodon.android.api.session.AccountSessionManager; import org.joinmastodon.android.fragments.account_list.FollowerListFragment; import org.joinmastodon.android.fragments.account_list.FollowingListFragment; import org.joinmastodon.android.fragments.report.ReportReasonChoiceFragment; +import org.joinmastodon.android.fragments.settings.SettingsServerFragment; import org.joinmastodon.android.model.Account; import org.joinmastodon.android.model.AccountField; import org.joinmastodon.android.model.Attachment; +import org.joinmastodon.android.model.Instance; import org.joinmastodon.android.model.Relationship; import org.joinmastodon.android.ui.BetterItemAnimator; +import org.joinmastodon.android.ui.M3AlertDialogBuilder; +import org.joinmastodon.android.ui.OutlineProviders; import org.joinmastodon.android.ui.SimpleViewHolder; import org.joinmastodon.android.ui.SingleImagePhotoViewerListener; -import org.joinmastodon.android.ui.drawables.CoverOverlayGradientDrawable; import org.joinmastodon.android.ui.photoviewer.PhotoViewer; import org.joinmastodon.android.ui.tabs.TabLayout; import org.joinmastodon.android.ui.tabs.TabLayoutMediator; @@ -82,9 +91,11 @@ 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.CustomDrawingOrderLinearLayout; import org.joinmastodon.android.ui.views.LinkedTextView; import org.joinmastodon.android.ui.views.NestedRecyclerScrollView; import org.joinmastodon.android.ui.views.ProgressBarButton; +import org.joinmastodon.android.utils.ElevationOnScrollListener; import org.joinmastodon.android.utils.ProvidesAssistContent; import org.parceler.Parcels; @@ -103,7 +114,6 @@ import androidx.recyclerview.widget.LinearLayoutManager; import androidx.recyclerview.widget.RecyclerView; import androidx.swiperefreshlayout.widget.SwipeRefreshLayout; import androidx.viewpager2.widget.ViewPager2; - import me.grishka.appkit.Nav; import me.grishka.appkit.api.Callback; import me.grishka.appkit.api.ErrorResponse; @@ -121,6 +131,7 @@ 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.FragmentRootLinearLayout; import me.grishka.appkit.views.UsableRecyclerView; public class ProfileFragment extends LoaderFragment implements OnBackPressedListener, ScrollableToTop, HasFab, ProvidesAssistContent.ProvidesWebUri { @@ -129,25 +140,27 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList private ImageView avatar; private CoverImageView cover; - private View avatarBorder, nameWrap; - private TextView name, username, bio, followersCount, followersLabel, followingCount, followingLabel, postsCount, postsLabel; + private View avatarBorder; + private TextView name, username, bio, followersCount, followersLabel, followingCount, followingLabel; private ProgressBarButton actionButton, notifyButton; private ViewPager2 pager; private NestedRecyclerScrollView scrollView; private AccountTimelineFragment postsFragment, postsWithRepliesFragment, mediaFragment; private PinnedPostsListFragment pinnedPostsFragment; -// private ProfileAboutFragment aboutFragment; private TabLayout tabbar; private SwipeRefreshLayout refreshLayout; - private CoverOverlayGradientDrawable coverGradient=new CoverOverlayGradientDrawable(); - private float titleTransY; - private View postsBtn, followersBtn, followingBtn, profileCounters; + private View followersBtn, followingBtn; private EditText nameEdit, bioEdit; private ProgressBar actionProgress, notifyProgress; private FrameLayout[] tabViews; private TabLayoutMediator tabLayoutMediator; private TextView followsYouView; private ViewGroup rolesView; + private LinearLayout countersLayout; + private View nameEditWrap, bioEditWrap; + private View tabsDivider; + private View actionButtonWrap; + private CustomDrawingOrderLinearLayout scrollableContent; public FrameLayout noteWrap; public EditText noteEdit; @@ -156,11 +169,10 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList private String accountID; private String domain; private Relationship relationship; - private int statusBarHeight; private boolean isOwnProfile; - private ArrayList fields=new ArrayList<>(); + private List fields=new ArrayList<>(); - private boolean isInEditMode; + private boolean isInEditMode, editDirty; private Uri editNewAvatar, editNewCover; private String profileAccountID; private boolean refreshing; @@ -168,21 +180,21 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList private WindowInsets childInsets; private PhotoViewer currentPhotoViewer; private boolean editModeLoading; + private ElevationOnScrollListener onScrollListener; + private Drawable tabsColorBackground; + private boolean tabBarIsAtTop; + private Animator tabBarColorAnim; + private MenuItem editSaveMenuItem; + private boolean savingEdits; - private int maxFields = 4; + private int maxFields = ProfileAboutFragment.MAX_FIELDS; // from ProfileAboutFragment public UsableRecyclerView list; - private List metadataListData=Collections.emptyList(); - private MetadataAdapter adapter; + private AboutAdapter adapter; private ItemTouchHelper dragHelper=new ItemTouchHelper(new ReorderCallback()); - private RecyclerView.ViewHolder draggedViewHolder; private ListImageLoaderWrapper imgLoader; - public ProfileFragment(){ - super(R.layout.loader_fragment_overlay_toolbar); - } - @Override public void onCreate(Bundle savedInstanceState){ super.onCreate(savedInstanceState); @@ -202,10 +214,9 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList loaded=true; if(!isOwnProfile) loadRelationship(); - else if (isInstanceAkkoma() && getInstance().isPresent()) - if(getInstance().get().pleroma != null && getInstance().get().pleroma.metadata != null && getInstance().get().pleroma.metadata.fieldsLimits != null){ - maxFields = getInstance().get().pleroma.metadata.fieldsLimits.maxFields; - } + else if (isInstanceAkkoma()) { + maxFields = getInstance().get().pleroma.metadata.fieldsLimits.maxFields; + } }else{ profileAccountID=getArguments().getString("profileAccountID"); if(!getArguments().getBoolean("noAutoLoad", false)) @@ -232,19 +243,14 @@ 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); followingCount=content.findViewById(R.id.following_count); followingLabel=content.findViewById(R.id.following_label); followingBtn=content.findViewById(R.id.following_btn); - postsCount=content.findViewById(R.id.posts_count); - postsLabel=content.findViewById(R.id.posts_label); - postsBtn=content.findViewById(R.id.posts_btn); actionButton=content.findViewById(R.id.profile_action_btn); notifyButton=content.findViewById(R.id.notify_btn); pager=content.findViewById(R.id.pager); @@ -253,24 +259,35 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList refreshLayout=content.findViewById(R.id.refresh_layout); nameEdit=content.findViewById(R.id.name_edit); bioEdit=content.findViewById(R.id.bio_edit); + nameEditWrap=content.findViewById(R.id.name_edit_wrap); + bioEditWrap=content.findViewById(R.id.bio_edit_wrap); actionProgress=content.findViewById(R.id.action_progress); notifyProgress=content.findViewById(R.id.notify_progress); fab=content.findViewById(R.id.fab); followsYouView=content.findViewById(R.id.follows_you); + countersLayout=content.findViewById(R.id.profile_counters); + tabsDivider=content.findViewById(R.id.tabs_divider); + actionButtonWrap=content.findViewById(R.id.profile_action_btn_wrap); + scrollableContent=content.findViewById(R.id.scrollable_content); list=content.findViewById(R.id.metadata); rolesView=content.findViewById(R.id.roles); + avatar.setOutlineProvider(OutlineProviders.roundedRect(24)); + avatar.setClipToOutline(true); + noteEdit = content.findViewById(R.id.note_edit); noteWrap = content.findViewById(R.id.note_edit_wrap); Button noteEditConfirm = content.findViewById(R.id.note_edit_confirm); - avatar.setOutlineProvider(new ViewOutlineProvider(){ - @Override - public void getOutline(View view, Outline outline){ - outline.setRoundRect(0, 0, view.getWidth(), view.getHeight(), V.dp(25)); + noteEditConfirm.setOnClickListener((v -> { + if (!noteEdit.getText().toString().trim().equals(note)) { + savePrivateNote(); } - }); - avatar.setClipToOutline(true); + InputMethodManager imm = (InputMethodManager) getContext().getSystemService(Activity.INPUT_METHOD_SERVICE); + imm.hideSoftInputFromWindow(this.getView().getRootView().getWindowToken(), 0); + noteEdit.clearFocus(); + })); + noteEdit.setOnFocusChangeListener((v, hasFocus) -> { if (hasFocus) { @@ -316,9 +333,7 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList FrameLayout sizeWrapper=new FrameLayout(getActivity()){ @Override protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec){ - Toolbar toolbar=getToolbar(); - pager.getLayoutParams().height=MeasureSpec.getSize(heightMeasureSpec)-getPaddingTop()-getPaddingBottom()-toolbar.getLayoutParams().height-statusBarHeight-V.dp(38); - coverGradient.setTopPadding(statusBarHeight+toolbar.getLayoutParams().height); + pager.getLayoutParams().height=MeasureSpec.getSize(heightMeasureSpec)-getPaddingTop()-getPaddingBottom()-V.dp(48); super.onMeasure(widthMeasureSpec, heightMeasureSpec); } }; @@ -331,7 +346,6 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList case 1 -> R.id.profile_posts_with_replies; case 2 -> R.id.profile_pinned_posts; case 3 -> R.id.profile_media; - case 4 -> R.id.profile_about; default -> throw new IllegalStateException("Unexpected value: "+i); }); tabView.setVisibility(View.GONE); @@ -346,11 +360,12 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList pager.getLayoutParams().height=getResources().getDisplayMetrics().heightPixels; scrollView.setScrollableChildSupplier(this::getScrollableRecyclerView); + scrollView.getViewTreeObserver().addOnGlobalLayoutListener(this::updateMetadataHeight); sizeWrapper.addView(content); - tabbar.setTabTextColors(UiUtils.getThemeColor(getActivity(), android.R.attr.textColorSecondary), UiUtils.getThemeColor(getActivity(), android.R.attr.textColorPrimary)); - tabbar.setTabTextSize(V.dp(16)); + tabbar.setTabTextColors(UiUtils.getThemeColor(getActivity(), R.attr.colorM3OnSurfaceVariant), UiUtils.getThemeColor(getActivity(), R.attr.colorM3Primary)); + tabbar.setTabTextSize(V.dp(14)); tabLayoutMediator=new TabLayoutMediator(tabbar, pager, new TabLayoutMediator.TabConfigurationStrategy(){ @Override public void onConfigureTab(@NonNull TabLayout.Tab tab, int position){ @@ -359,13 +374,12 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList case 1 -> R.string.posts_and_replies; case 2 -> R.string.sk_pinned_posts; case 3 -> R.string.media; - case 4 -> R.string.profile_about; default -> throw new IllegalStateException(); }); + if (position == 4) tab.view.setVisibility(View.GONE); } }); - cover.setForeground(coverGradient); cover.setOutlineProvider(new ViewOutlineProvider(){ @Override public void getOutline(View view, Outline outline){ @@ -382,6 +396,13 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList fab.setOnClickListener(this::onFabClick); fab.setOnLongClickListener(v->UiUtils.pickAccountForCompose(getActivity(), accountID, getPrefilledText())); + if(savedInstanceState!=null){ + postsFragment=(AccountTimelineFragment) getChildFragmentManager().getFragment(savedInstanceState, "posts"); + postsWithRepliesFragment=(AccountTimelineFragment) getChildFragmentManager().getFragment(savedInstanceState, "postsWithReplies"); + mediaFragment=(AccountTimelineFragment) getChildFragmentManager().getFragment(savedInstanceState, "media"); + pinnedPostsFragment=(PinnedPostsListFragment) getChildFragmentManager().getFragment(savedInstanceState, "pinnedPosts"); + } + if(loaded){ bindHeaderView(); dataLoaded(); @@ -393,14 +414,29 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList followersBtn.setOnClickListener(this::onFollowersOrFollowingClick); followingBtn.setOnClickListener(this::onFollowersOrFollowingClick); + username.setOnClickListener(v->{ + try { + new GetInstance() + .setCallback(new Callback<>(){ + @Override + public void onSuccess(Instance result){ + Bundle args = new Bundle(); + args.putParcelable("instance", Parcels.wrap(result)); + args.putString("account", accountID); + Nav.go(getActivity(), SettingsServerFragment.class, args); + } - //this currently takes up the whole username - //in the future it might need to be change to only the instance uri - username.setOnClickListener(v -> { - Bundle args=new Bundle(); - args.putString("account", accountID); - args.putString("instanceDomain", Uri.parse(account.url).getHost()); - Nav.go(getActivity(), InstanceInfoFragment.class, args); + @Override + public void onError(ErrorResponse error){ + error.showToast(getContext()); + } + }) + .wrapProgress((Activity) getContext(), R.string.loading, true) + .execRemote(Uri.parse(account.url).getHost()); + } catch (NullPointerException ignored) { + // maybe the url was malformed? + Toast.makeText(getContext(), R.string.error, Toast.LENGTH_SHORT); + } }); username.setOnLongClickListener(v->{ @@ -417,9 +453,25 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList list.setDrawSelectorOnTop(true); list.setLayoutManager(new LinearLayoutManager(getActivity())); imgLoader=new ListImageLoaderWrapper(getActivity(), list, new RecyclerViewDelegate(list), null); - list.setAdapter(adapter=new MetadataAdapter()); + list.setAdapter(adapter=new AboutAdapter()); list.setClipToPadding(false); + scrollableContent.setDrawingOrderCallback((count, pos)->{ + // The header is the first child, draw it last to overlap everything for the photo viewer transition to look nice + if(pos==count-1) + return 0; + // Offset the order of other child views to compensate + return pos+1; + }); + + int colorBackground=UiUtils.getThemeColor(getActivity(), R.attr.colorM3Background); + int colorPrimary=UiUtils.getThemeColor(getActivity(), R.attr.colorM3Primary); + refreshLayout.setProgressBackgroundColorSchemeColor(UiUtils.alphaBlendColors(colorBackground, colorPrimary, 0.11f)); + refreshLayout.setColorSchemeColors(colorPrimary); + + nameEdit.addTextChangedListener(new SimpleTextWatcher(e->editDirty=true)); + bioEdit.addTextChangedListener(new SimpleTextWatcher(e->editDirty=true)); + return sizeWrapper; } @@ -513,20 +565,24 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList public void dataLoaded(){ if(getActivity()==null) return; + Bundle args=new Bundle(); + args.putString("account", accountID); + args.putParcelable("profileAccount", Parcels.wrap(account)); + args.putBoolean("__is_tab", true); if(postsFragment==null){ postsFragment=AccountTimelineFragment.newInstance(accountID, account, GetAccountStatuses.Filter.DEFAULT, true); + } + if(postsWithRepliesFragment==null){ postsWithRepliesFragment=AccountTimelineFragment.newInstance(accountID, account, GetAccountStatuses.Filter.INCLUDE_REPLIES, false); + } + if(mediaFragment==null){ mediaFragment=AccountTimelineFragment.newInstance(accountID, account, GetAccountStatuses.Filter.MEDIA, false); - - Bundle args=new Bundle(); - args.putString("account", accountID); - args.putParcelable("profileAccount", Parcels.wrap(account)); - args.putBoolean("__is_tab", true); + } + if(pinnedPostsFragment==null){ pinnedPostsFragment=new PinnedPostsListFragment(); pinnedPostsFragment.setArguments(args); -// aboutFragment=new ProfileAboutFragment(); - setFields(fields); } + setFields(fields); pager.getAdapter().notifyDataSetChanged(); super.dataLoaded(); } @@ -543,10 +599,8 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList pager.registerOnPageChangeCallback(new ViewPager2.OnPageChangeCallback(){ @Override public void onPageSelected(int position){ - if(position==0) - return; Fragment _page=getFragmentForPage(position); - if(_page instanceof BaseRecyclerFragment page){ + if(_page instanceof BaseRecyclerFragment page && page.isAdded()){ if(!page.loaded && !page.isDataLoading()) page.loadData(); } @@ -554,6 +608,8 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList @Override public void onPageScrollStateChanged(int state){ + if(isInEditMode) + return; refreshLayout.setEnabled(state!=ViewPager2.SCROLL_STATE_DRAGGING); } }); @@ -561,18 +617,39 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList } }); + tabsColorBackground=((LayerDrawable)tabbar.getBackground()).findDrawableByLayerId(R.id.color_overlay); + + onScrollListener=new ElevationOnScrollListener((FragmentRootLinearLayout) view, getToolbar()); scrollView.setOnScrollChangeListener(this::onScrollChanged); - titleTransY=getToolbar().getLayoutParams().height; - if(toolbarTitleView!=null){ - toolbarTitleView.setTranslationY(titleTransY); - toolbarSubtitleView.setTranslationY(titleTransY); - } - RecyclerFragment.setRefreshLayoutColors(refreshLayout); + scrollView.getViewTreeObserver().addOnPreDrawListener(new ViewTreeObserver.OnPreDrawListener(){ + @Override + public boolean onPreDraw(){ + scrollView.getViewTreeObserver().removeOnPreDrawListener(this); + + tabBarIsAtTop=!scrollView.canScrollVertically(1) && scrollView.getHeight()>0; + if (UiUtils.isTrueBlackTheme()) tabBarIsAtTop=false; + tabsColorBackground.setAlpha(tabBarIsAtTop ? 20 : 0); + tabbar.setTranslationZ(tabBarIsAtTop ? V.dp(3) : 0); + tabsDivider.setAlpha(tabBarIsAtTop ? 0 : 1); + + return true; + } + }); } @Override - public void onDestroyView(){ - super.onDestroyView(); + public void onSaveInstanceState(Bundle outState){ + super.onSaveInstanceState(outState); + if(postsFragment==null) + return; + if(postsFragment.isAdded()) + getChildFragmentManager().putFragment(outState, "posts", postsFragment); + if(postsWithRepliesFragment.isAdded()) + getChildFragmentManager().putFragment(outState, "postsWithReplies", postsWithRepliesFragment); + if(mediaFragment.isAdded()) + getChildFragmentManager().putFragment(outState, "media", mediaFragment); + if(pinnedPostsFragment.isAdded()) + getChildFragmentManager().putFragment(outState, "pinnedPosts", pinnedPostsFragment); } @Override @@ -583,21 +660,18 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList @Override public void onApplyWindowInsets(WindowInsets insets){ - statusBarHeight=insets.getSystemWindowInsetTop(); if(contentView!=null){ - ((ViewGroup.MarginLayoutParams) getToolbar().getLayoutParams()).topMargin=statusBarHeight; - refreshLayout.setProgressViewEndTarget(true, statusBarHeight+refreshLayout.getProgressCircleDiameter()+V.dp(24)); if(Build.VERSION.SDK_INT>=29 && insets.getTappableElementInsets().bottom==0){ int insetBottom=insets.getSystemWindowInsetBottom(); childInsets=insets.inset(insets.getSystemWindowInsetLeft(), insets.getSystemWindowInsetTop(), insets.getSystemWindowInsetRight(), 0); - ((ViewGroup.MarginLayoutParams) fab.getLayoutParams()).bottomMargin=V.dp(24)+insetBottom; + ((ViewGroup.MarginLayoutParams) fab.getLayoutParams()).bottomMargin=V.dp(16)+insetBottom; applyChildWindowInsets(); insets=insets.inset(0, 0, 0, insetBottom); }else{ - ((ViewGroup.MarginLayoutParams) fab.getLayoutParams()).bottomMargin=V.dp(24); + ((ViewGroup.MarginLayoutParams) fab.getLayoutParams()).bottomMargin=V.dp(16); } } - super.onApplyWindowInsets(insets.replaceSystemWindowInsets(insets.getSystemWindowInsetLeft(), 0, insets.getSystemWindowInsetRight(), insets.getSystemWindowInsetBottom())); + super.onApplyWindowInsets(insets); } private void applyChildWindowInsets(){ @@ -615,7 +689,8 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList ViewImageLoader.load(avatar, null, new UrlImageLoaderRequest(GlobalUserPreferences.playGifs ? account.avatar : account.avatarStatic, V.dp(100), V.dp(100))); ViewImageLoader.load(cover, null, new UrlImageLoaderRequest(GlobalUserPreferences.playGifs ? account.header : account.headerStatic, 1000, 1000)); SpannableStringBuilder ssb=new SpannableStringBuilder(account.displayName); - HtmlParser.parseCustomEmoji(ssb, account.emojis); + if(AccountSessionManager.get(accountID).getLocalPreferences().customEmojiInNames) + HtmlParser.parseCustomEmoji(ssb, account.emojis); name.setText(ssb); setTitle(ssb); @@ -626,9 +701,10 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList for (Account.Role role : account.roles) { TextView roleText = new TextView(getActivity(), null, 0, R.style.role_label); roleText.setText(role.name); + roleText.setGravity(Gravity.CENTER_VERTICAL); if (!TextUtils.isEmpty(role.color) && role.color.startsWith("#")) try { GradientDrawable bg = (GradientDrawable) roleText.getBackground().mutate(); - bg.setStroke(V.dp(2), Color.parseColor(role.color)); + bg.setStroke(V.dp(1), Color.parseColor(role.color)); } catch (Exception ignored) {} rolesView.addView(roleText); } @@ -677,13 +753,13 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList } followersCount.setText(UiUtils.abbreviateNumber(account.followersCount)); followingCount.setText(UiUtils.abbreviateNumber(account.followingCount)); - postsCount.setText(UiUtils.abbreviateNumber(account.statusesCount)); followersLabel.setText(getResources().getQuantityString(R.plurals.followers, (int)Math.min(999, account.followersCount))); followingLabel.setText(getResources().getQuantityString(R.plurals.following, (int)Math.min(999, account.followingCount))); - postsLabel.setText(getResources().getQuantityString(R.plurals.posts, (int)Math.min(999, account.statusesCount))); if (account.followersCount < 0) followersBtn.setVisibility(View.GONE); if (account.followingCount < 0) followingBtn.setVisibility(View.GONE); + if (account.followersCount < 0 || account.followingCount < 0) + countersLayout.findViewById(R.id.profile_counters_separator).setVisibility(View.GONE); UiUtils.loadCustomEmojiInTextView(name); UiUtils.loadCustomEmojiInTextView(bio); @@ -691,6 +767,12 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList notifyButton.setVisibility(View.GONE); if(AccountSessionManager.getInstance().isSelf(accountID, account)){ actionButton.setText(R.string.edit_profile); + TypedArray ta=actionButton.getContext().obtainStyledAttributes(R.style.Widget_Mastodon_M3_Button_Tonal, new int[]{android.R.attr.background}); + actionButton.setBackground(ta.getDrawable(0)); + ta.recycle(); + ta=actionButton.getContext().obtainStyledAttributes(R.style.Widget_Mastodon_M3_Button_Tonal, new int[]{android.R.attr.textColor}); + actionButton.setTextColor(ta.getColorStateList(0)); + ta.recycle(); }else{ actionButton.setVisibility(View.GONE); } @@ -725,18 +807,11 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList } private void updateToolbar(){ - getToolbar().setBackgroundColor(0); - if(toolbarTitleView!=null){ - toolbarTitleView.setTranslationY(titleTransY); - toolbarSubtitleView.setTranslationY(titleTransY); - } getToolbar().setOnClickListener(v->scrollToTop()); getToolbar().setNavigationContentDescription(R.string.back); - } - - @Override - public boolean wantsLightStatusBar(){ - return false; + if(onScrollListener!=null){ + onScrollListener.setViews(getToolbar()); + } } @Override @@ -747,16 +822,10 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList @Override public void onCreateOptionsMenu(Menu menu, MenuInflater inflater){ if(isOwnProfile && isInEditMode){ - Button cancelButton=new Button(getActivity(), null, 0, R.style.Widget_Mastodon_Button_Secondary_LightOnDark); - cancelButton.setText(R.string.cancel); - cancelButton.setOnClickListener(v->exitEditMode()); - FrameLayout wrap=new FrameLayout(getActivity()); - wrap.addView(cancelButton, new FrameLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT, Gravity.TOP|Gravity.LEFT)); - wrap.setPadding(V.dp(16), V.dp(4), V.dp(16), V.dp(8)); - wrap.setClipToPadding(false); - MenuItem item=menu.add(R.string.cancel); - item.setActionView(wrap); - item.setShowAsAction(MenuItem.SHOW_AS_ACTION_ALWAYS); + editSaveMenuItem=menu.add(0, R.id.save, 0, R.string.save_changes); + editSaveMenuItem.setIcon(R.drawable.ic_fluent_save_24_regular); + editSaveMenuItem.setShowAsAction(MenuItem.SHOW_AS_ACTION_ALWAYS); + editSaveMenuItem.setVisible(!isActionButtonInView()); return; } if(relationship==null && !isOwnProfile) @@ -777,7 +846,7 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList getActivity(), s.getID(), account.url, false )); } - menu.findItem(R.id.share).setTitle(getString(R.string.share_user, account.getShortUsername())); + menu.findItem(R.id.share).setTitle(R.string.share_user); if(isOwnProfile) { if (isInstancePixelfed()) menu.findItem(R.id.scheduled).setVisible(false); return; @@ -787,18 +856,17 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList mute.setTitle(getString(relationship.muting ? R.string.unmute_user : R.string.mute_user, account.getShortUsername())); mute.setIcon(relationship.muting ? R.drawable.ic_fluent_speaker_0_24_regular : R.drawable.ic_fluent_speaker_off_24_regular); UiUtils.insetPopupMenuIcon(getContext(), mute); - menu.findItem(R.id.block).setTitle(getString(relationship.blocking ? R.string.unblock_user : R.string.block_user, account.getShortUsername())); menu.findItem(R.id.report).setTitle(getString(R.string.report_user, account.getShortUsername())); menu.findItem(R.id.manage_user_lists).setVisible(relationship.following); menu.findItem(R.id.soft_block).setVisible(relationship.followedBy && !relationship.following); - if(relationship.following) { + if (relationship.following) { MenuItem hideBoosts = menu.findItem(R.id.hide_boosts); hideBoosts.setTitle(getString(relationship.showingReblogs ? R.string.hide_boosts_from_user : R.string.show_boosts_from_user, account.getShortUsername())); hideBoosts.setIcon(relationship.showingReblogs ? R.drawable.ic_fluent_arrow_repeat_all_off_24_regular : R.drawable.ic_fluent_arrow_repeat_all_24_regular); UiUtils.insetPopupMenuIcon(getContext(), hideBoosts); menu.findItem(R.id.manage_user_lists).setTitle(getString(R.string.sk_lists_with_user, account.getShortUsername())); - }else { + } else { menu.findItem(R.id.hide_boosts).setVisible(false); } if(!account.isLocal()) @@ -810,8 +878,8 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList @Override public boolean onOptionsItemSelected(MenuItem item){ int id=item.getItemId(); - if(id==R.id.share) { - Intent intent = new Intent(Intent.ACTION_SEND); + if(id==R.id.share){ + Intent intent=new Intent(Intent.ACTION_SEND); intent.setType("text/plain"); intent.putExtra(Intent.EXTRA_TEXT, account.url); startActivity(Intent.createChooser(intent, item.getTitle())); @@ -825,6 +893,7 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList Bundle args=new Bundle(); args.putString("account", accountID); args.putParcelable("reportAccount", Parcels.wrap(account)); + args.putParcelable("relationship", Parcels.wrap(relationship)); Nav.go(getActivity(), ReportReasonChoiceFragment.class, args); }else if(id==R.id.open_in_browser){ UiUtils.launchWebBrowser(getActivity(), account.url); @@ -872,15 +941,13 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList Bundle args=new Bundle(); args.putString("account", accountID); Nav.go(getActivity(), ScheduledStatusListFragment.class, args); + }else if(id==R.id.save){ + if(isInEditMode) + saveAndExitEditMode(); } return true; } - @Override - protected int getToolbarResource(){ - return R.layout.profile_toolbar; - } - private void loadRelationship(){ new GetAccountRelationships(Collections.singletonList(account.id)) .setCallback(new Callback<>(){ @@ -905,8 +972,7 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList invalidateOptionsMenu(); actionButton.setVisibility(View.VISIBLE); notifyButton.setVisibility(relationship.following ? View.VISIBLE : View.GONE); - UiUtils.setRelationshipToActionButton(relationship, actionButton); - UiUtils.setRelationshipToActionButton(relationship, notifyButton, true); + UiUtils.setRelationshipToActionButtonM3(relationship, actionButton); actionProgress.setIndeterminateTintList(actionButton.getTextColors()); notifyProgress.setIndeterminateTintList(notifyButton.getTextColors()); followsYouView.setVisibility(relationship.followedBy ? View.VISIBLE : View.GONE); @@ -939,35 +1005,59 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList } private void onScrollChanged(View v, int scrollX, int scrollY, int oldScrollX, int oldScrollY){ - int topBarsH=getToolbar().getHeight()+statusBarHeight; - if(scrollY>avatarBorder.getTop()-topBarsH){ - float avaAlpha=Math.max(1f-((scrollY-(avatarBorder.getTop()-topBarsH))/(float)V.dp(38)), 0f); - avatarBorder.setAlpha(avaAlpha); - }else{ - avatarBorder.setAlpha(1f); - } - if(scrollY>cover.getHeight()-topBarsH){ - cover.setTranslationY(scrollY-(cover.getHeight()-topBarsH)); + if(scrollY>cover.getHeight()){ + cover.setTranslationY(scrollY-(cover.getHeight())); cover.setTranslationZ(V.dp(10)); - cover.setTransform(cover.getHeight()/2f-topBarsH/2f, 1f); + cover.setTransform(cover.getHeight()/2f); }else{ cover.setTranslationY(0f); cover.setTranslationZ(0f); - cover.setTransform(scrollY/2f, 1f); + cover.setTransform(scrollY/2f); } - coverGradient.setTopOffset(scrollY); cover.invalidate(); - titleTransY=getToolbar().getHeight(); - if(scrollY>nameWrap.getTop()-topBarsH){ - titleTransY=Math.max(0f, titleTransY-(scrollY-(nameWrap.getTop()-topBarsH))); - } - if(toolbarTitleView!=null){ - toolbarTitleView.setTranslationY(titleTransY); - toolbarSubtitleView.setTranslationY(titleTransY); - } if(currentPhotoViewer!=null){ currentPhotoViewer.offsetView(0, oldScrollY-scrollY); } + onScrollListener.onScrollChange(v, scrollX, scrollY, oldScrollX, oldScrollY); + + boolean newTabBarIsAtTop=!scrollView.canScrollVertically(1); + if(newTabBarIsAtTop!=tabBarIsAtTop){ + if(UiUtils.isTrueBlackTheme()) newTabBarIsAtTop=false; + tabBarIsAtTop=newTabBarIsAtTop; + + if(tabBarIsAtTop){ + // ScrollView would sometimes leave 1 pixel unscrolled, force it into the correct scrollY + int maxY=scrollView.getChildAt(0).getHeight()-scrollView.getHeight(); + if(scrollView.getScrollY()!=maxY) + scrollView.scrollTo(0, maxY); + } + + if(tabBarColorAnim!=null) + tabBarColorAnim.cancel(); + AnimatorSet set=new AnimatorSet(); + set.playTogether( + ObjectAnimator.ofInt(tabsColorBackground, "alpha", tabBarIsAtTop ? 20 : 0), + ObjectAnimator.ofFloat(tabbar, View.TRANSLATION_Z, tabBarIsAtTop ? V.dp(3) : 0), + ObjectAnimator.ofFloat(getToolbar(), View.TRANSLATION_Z, tabBarIsAtTop ? 0 : V.dp(3)), + ObjectAnimator.ofFloat(tabsDivider, View.ALPHA, tabBarIsAtTop ? 0 : 1) + ); + set.setDuration(150); + set.setInterpolator(CubicBezierInterpolator.DEFAULT); + set.addListener(new AnimatorListenerAdapter(){ + @Override + public void onAnimationEnd(Animator animation){ + tabBarColorAnim=null; + } + }); + tabBarColorAnim=set; + set.start(); + } + if(isInEditMode && editSaveMenuItem!=null){ + boolean buttonInView=isActionButtonInView(); + if(buttonInView==editSaveMenuItem.isVisible()){ + editSaveMenuItem.setVisible(!buttonInView); + } + } } private Fragment getFragmentForPage(int page){ @@ -976,13 +1066,13 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList case 1 -> postsWithRepliesFragment; case 2 -> pinnedPostsFragment; case 3 -> mediaFragment; -// case 4 -> aboutFragment; default -> throw new IllegalStateException(); }; } private RecyclerView getScrollableRecyclerView(){ - return getFragmentForPage(pager.getCurrentItem()).getView().findViewById(R.id.list); + return isInEditMode ? list : + getFragmentForPage(pager.getCurrentItem()).getView().findViewById(R.id.list); } private void onActionButtonClick(View v){ @@ -1024,12 +1114,16 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList private void setActionProgressVisible(boolean visible){ actionButton.setTextVisible(!visible); actionProgress.setVisibility(visible ? View.VISIBLE : View.GONE); + if(visible) + actionProgress.setIndeterminateTintList(actionButton.getTextColors()); actionButton.setClickable(!visible); } private void setNotifyProgressVisible(boolean visible){ notifyButton.setTextVisible(!visible); notifyProgress.setVisibility(visible ? View.VISIBLE : View.GONE); + if(visible) + notifyProgress.setIndeterminateTintList(notifyButton.getTextColors()); notifyButton.setClickable(!visible); } @@ -1043,7 +1137,8 @@ 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); } @@ -1051,7 +1146,8 @@ 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); } @@ -1059,46 +1155,60 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList .exec(accountID); } + private void updateMetadataHeight() { + ViewGroup.LayoutParams params = list.getLayoutParams(); + int desiredHeight = isInEditMode ? scrollView.getHeight() : ViewGroup.LayoutParams.WRAP_CONTENT; + if (params.height == desiredHeight) return; + params.height = desiredHeight; + list.requestLayout(); + } + private void enterEditMode(Account account){ if(isInEditMode) throw new IllegalStateException(); isInEditMode=true; - invalidateOptionsMenu(); - pager.setUserInputEnabled(false); - actionButton.setText(R.string.done); - ArrayList animators=new ArrayList<>(); - Drawable overlay=getResources().getDrawable(R.drawable.edit_avatar_overlay, getActivity().getTheme()).mutate(); - avatar.setForeground(overlay); - animators.add(ObjectAnimator.ofInt(overlay, "alpha", 0, 255)); - - nameWrap.setVisibility(View.GONE); - nameEdit.setVisibility(View.VISIBLE); - nameEdit.setText(account.displayName); - RelativeLayout.LayoutParams lp=(RelativeLayout.LayoutParams) username.getLayoutParams(); - lp.addRule(RelativeLayout.BELOW, R.id.name_edit); - username.getParent().requestLayout(); - animators.add(ObjectAnimator.ofFloat(nameEdit, View.ALPHA, 0f, 1f)); - - bioEdit.setVisibility(View.VISIBLE); - bioEdit.setText(account.source.note); - animators.add(ObjectAnimator.ofFloat(bioEdit, View.ALPHA, 0f, 1f)); - animators.add(ObjectAnimator.ofFloat(bio, View.ALPHA, 0f)); - profileCounters.setVisibility(View.GONE); - pager.setVisibility(View.GONE); - tabbar.setVisibility(View.GONE); - - AnimatorSet set=new AnimatorSet(); - set.playTogether(animators); - set.setDuration(300); - set.setInterpolator(CubicBezierInterpolator.DEFAULT); - set.start(); - -// aboutFragment.enterEditMode(account.source.fields); - - V.setVisibilityAnimated(fab, View.GONE); - metadataListData=account.source.fields; adapter.notifyDataSetChanged(); dragHelper.attachToRecyclerView(list); + editDirty=false; + invalidateOptionsMenu(); + actionButton.setText(R.string.save_changes); + pager.setVisibility(View.GONE); + tabbar.setVisibility(View.GONE); + Drawable overlay=getResources().getDrawable(R.drawable.edit_avatar_overlay).mutate(); + avatar.setForeground(overlay); + updateMetadataHeight(); + + Toolbar toolbar=getToolbar(); + Drawable close=getToolbarContext().getDrawable(R.drawable.ic_baseline_close_24).mutate(); + close.setTint(UiUtils.getThemeColor(getToolbarContext(), R.attr.colorM3OnSurfaceVariant)); + toolbar.setNavigationIcon(close); + toolbar.setNavigationContentDescription(R.string.discard); + + ViewGroup parent=contentView.findViewById(R.id.scrollable_content); + TransitionManager.beginDelayedTransition(parent, new TransitionSet() + .addTransition(new Fade(Fade.IN | Fade.OUT)) + .addTransition(new ChangeBounds()) + .setDuration(250) + .setInterpolator(CubicBezierInterpolator.DEFAULT) + ); + + name.setVisibility(View.GONE); + username.setVisibility(View.GONE); + bio.setVisibility(View.GONE); + countersLayout.setVisibility(View.GONE); + + nameEditWrap.setVisibility(View.VISIBLE); + nameEdit.setText(account.displayName); + + bioEditWrap.setVisibility(View.VISIBLE); + bioEdit.setText(account.source.note); + + refreshLayout.setEnabled(false); + editDirty=false; + V.setVisibilityAnimated(fab, View.GONE); + + fields = account.source.fields; + adapter.notifyDataSetChanged(); } private void exitEditMode(){ @@ -1107,35 +1217,37 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList isInEditMode=false; invalidateOptionsMenu(); - ArrayList animators=new ArrayList<>(); actionButton.setText(R.string.edit_profile); - animators.add(ObjectAnimator.ofInt(avatar.getForeground(), "alpha", 0)); - animators.add(ObjectAnimator.ofFloat(nameEdit, View.ALPHA, 0f)); - animators.add(ObjectAnimator.ofFloat(bioEdit, View.ALPHA, 0f)); - animators.add(ObjectAnimator.ofFloat(bio, View.ALPHA, 1f)); - profileCounters.setVisibility(View.VISIBLE); + avatar.setForeground(null); + + Toolbar toolbar=getToolbar(); + if(canGoBack()){ + Drawable back=getToolbarContext().getDrawable(R.drawable.ic_fluent_arrow_left_24_regular).mutate(); + back.setTint(UiUtils.getThemeColor(getToolbarContext(), R.attr.colorM3OnSurfaceVariant)); + toolbar.setNavigationIcon(back); + toolbar.setNavigationContentDescription(0); + }else{ + toolbar.setNavigationIcon(null); + } + editSaveMenuItem=null; + + ViewGroup parent=contentView.findViewById(R.id.scrollable_content); + TransitionManager.beginDelayedTransition(parent, new TransitionSet() + .addTransition(new Fade(Fade.IN | Fade.OUT)) + .addTransition(new ChangeBounds()) + .setDuration(250) + .setInterpolator(CubicBezierInterpolator.DEFAULT) + ); + nameEditWrap.setVisibility(View.GONE); + bioEditWrap.setVisibility(View.GONE); + name.setVisibility(View.VISIBLE); + username.setVisibility(View.VISIBLE); + bio.setVisibility(View.VISIBLE); + countersLayout.setVisibility(View.VISIBLE); + refreshLayout.setEnabled(true); pager.setVisibility(View.VISIBLE); tabbar.setVisibility(View.VISIBLE); - V.setVisibilityAnimated(nameWrap, View.VISIBLE); - - AnimatorSet set=new AnimatorSet(); - set.playTogether(animators); - set.setDuration(200); - set.setInterpolator(CubicBezierInterpolator.DEFAULT); - set.addListener(new AnimatorListenerAdapter(){ - @Override - public void onAnimationEnd(Animator animation){ - pager.setUserInputEnabled(true); - nameEdit.setVisibility(View.GONE); - bioEdit.setVisibility(View.GONE); - RelativeLayout.LayoutParams lp=(RelativeLayout.LayoutParams) username.getLayoutParams(); - lp.addRule(RelativeLayout.BELOW, R.id.name_wrap); - username.getParent().requestLayout(); - avatar.setForeground(null); - scrollToTop(); - } - }); - set.start(); + updateMetadataHeight(); InputMethodManager imm=getActivity().getSystemService(InputMethodManager.class); imm.hideSoftInputFromWindow(content.getWindowToken(), 0); @@ -1147,10 +1259,12 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList if(!isInEditMode) throw new IllegalStateException(); setActionProgressVisible(true); - new UpdateAccountCredentials(nameEdit.getText().toString(), bioEdit.getText().toString(), editNewAvatar, editNewCover, metadataListData) + savingEdits=true; + new UpdateAccountCredentials(nameEdit.getText().toString(), bioEdit.getText().toString(), editNewAvatar, editNewCover, fields) .setCallback(new Callback<>(){ @Override public void onSuccess(Account result){ + savingEdits=false; account=result; AccountSessionManager.getInstance().updateAccountInfo(accountID, account); if (getActivity() == null) return; @@ -1160,6 +1274,7 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList @Override public void onError(ErrorResponse error){ + savingEdits=false; error.showToast(getActivity()); setActionProgressVisible(false); } @@ -1190,7 +1305,17 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList savePrivateNote(); } if(isInEditMode){ - exitEditMode(); + if(savingEdits) + return true; + if(editDirty){ + new M3AlertDialogBuilder(getActivity()) + .setTitle(R.string.discard_changes) + .setPositiveButton(R.string.discard, (dlg, btn)->exitEditMode()) + .setNegativeButton(R.string.cancel, null) + .show(); + }else{ + exitEditMode(); + } return true; } return false; @@ -1243,9 +1368,7 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList } private void startImagePicker(int requestCode){ - Intent intent=new Intent(Intent.ACTION_GET_CONTENT); - intent.setType("image/*"); - intent.addCategory(Intent.CATEGORY_OPENABLE); + Intent intent=UiUtils.getMediaPickerIntent(new String[]{"image/*"}, 1); startActivityForResult(intent, requestCode); } @@ -1254,10 +1377,12 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList if(resultCode==Activity.RESULT_OK){ if(requestCode==AVATAR_RESULT){ editNewAvatar=data.getData(); - ViewImageLoader.load(avatar, null, new UrlImageLoaderRequest(editNewAvatar, V.dp(100), V.dp(100))); + ViewImageLoader.loadWithoutAnimation(avatar, null, new UrlImageLoaderRequest(editNewAvatar, V.dp(100), V.dp(100))); + editDirty=true; }else if(requestCode==COVER_RESULT){ editNewCover=data.getData(); - ViewImageLoader.load(cover, null, new UrlImageLoaderRequest(editNewCover, V.dp(1000), V.dp(1000))); + ViewImageLoader.loadWithoutAnimation(cover, null, new UrlImageLoaderRequest(editNewCover, V.dp(1000), V.dp(1000))); + editDirty=true; } } } @@ -1282,6 +1407,10 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList Nav.go(getActivity(), cls, args); } + private boolean isActionButtonInView(){ + return actionButton.getVisibility()==View.VISIBLE && actionButtonWrap.getTop()+actionButtonWrap.getHeight()>scrollView.getScrollY(); + } + private class ProfilePagerAdapter extends RecyclerView.Adapter{ @NonNull @Override @@ -1301,6 +1430,7 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList holder.itemView.getViewTreeObserver().addOnPreDrawListener(new ViewTreeObserver.OnPreDrawListener(){ @Override public boolean onPreDraw(){ + getChildFragmentManager().executePendingTransactions(); if(fragment.isAdded()){ holder.itemView.getViewTreeObserver().removeOnPreDrawListener(this); applyChildWindowInsets(); @@ -1322,16 +1452,6 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList } } - // from ProfileAboutFragment - public void setFields(ArrayList fields){ - metadataListData=fields; - if (isInEditMode) { - isInEditMode=false; - dragHelper.attachToRecyclerView(null); - } - if (adapter != null) adapter.notifyDataSetChanged(); - } - @Override public void onProvideAssistContent(AssistContent assistContent) { callFragmentToProvideAssistContent(getFragmentForPage(pager.getCurrentItem()), assistContent); @@ -1347,8 +1467,19 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList return Uri.parse(account.url); } - private class MetadataAdapter extends UsableRecyclerView.Adapter implements ImageLoaderRecyclerAdapter { - public MetadataAdapter(){ + // from ProfileAboutFragment + public void setFields(List fields){ + this.fields=fields; + if(isInEditMode){ + isInEditMode=false; +// dragHelper.attachToRecyclerView(null); + } + if(adapter!=null) + adapter.notifyDataSetChanged(); + } + + private class AboutAdapter extends UsableRecyclerView.Adapter implements ImageLoaderRecyclerAdapter { + public AboutAdapter(){ super(imgLoader); } @@ -1365,8 +1496,8 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList @Override public void onBindViewHolder(BaseViewHolder holder, int position){ - if(position{ dragHelper.startDrag(this); return true; }); - title.addTextChangedListener(new SimpleTextWatcher(e->item.name=e.toString())); - title.setOnFocusChangeListener(new View.OnFocusChangeListener() { - @Override - public void onFocusChange(View v, boolean hasFocus) { - titleHasFocus = hasFocus; - } - }); - value.addTextChangedListener(new SimpleTextWatcher(e->item.value=e.toString())); - value.setOnFocusChangeListener(new View.OnFocusChangeListener() { - @Override - public void onFocusChange(View v, boolean hasFocus) { - valueHasFocus = hasFocus; - } - }); - findViewById(R.id.remove_row_btn).setOnClickListener(this::onRemoveRowClick); + title.addTextChangedListener(new SimpleTextWatcher(e->{ + item.name=e.toString(); + if(!ignoreTextChange) + editDirty=true; + })); + value.addTextChangedListener(new SimpleTextWatcher(e->{ + item.value=e.toString(); + if(!ignoreTextChange) + editDirty=true; + })); + findViewById(R.id.delete).setOnClickListener(this::onRemoveRowClick); } @Override public void onBind(AccountField item){ + super.onBind(item); + ignoreTextChange=true; title.setText(item.name); value.setText(item.value); + ignoreTextChange=false; } private void onRemoveRowClick(View v){ - if(titleHasFocus || valueHasFocus){ - return; - } - int pos=getAbsoluteAdapterPosition(); - metadataListData.remove(pos); + fields.remove(pos); adapter.notifyItemRemoved(pos); for(int i=0;itoPosition;i--) { - Collections.swap(metadataListData, i, i-1); + Collections.swap(fields, i, i-1); } } adapter.notifyItemMoved(fromPosition, toPosition); - ((BindableViewHolder)viewHolder).rebind(); - ((BindableViewHolder)target).rebind(); + ((BindableViewHolder)viewHolder).rebind(); + ((BindableViewHolder)target).rebind(); return true; } @@ -1560,7 +1680,6 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList if(actionState==ItemTouchHelper.ACTION_STATE_DRAG){ viewHolder.itemView.setTag(me.grishka.appkit.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; } } @@ -1568,7 +1687,6 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList 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 diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/RecyclerFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/RecyclerFragment.java deleted file mode 100644 index 2dbc9650b..000000000 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/RecyclerFragment.java +++ /dev/null @@ -1,50 +0,0 @@ -package org.joinmastodon.android.fragments; - -import android.content.Context; -import android.os.Bundle; -import android.view.View; - -import androidx.swiperefreshlayout.widget.SwipeRefreshLayout; - -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 me.grishka.appkit.fragments.BaseRecyclerFragment; - - -public abstract class RecyclerFragment extends BaseRecyclerFragment { - public RecyclerFragment(int perPage) { - super(perPage); - } - - public RecyclerFragment(int layout, int perPage) { - super(layout, perPage); - } - - public void onViewCreated(View view, Bundle savedInstanceState) { - super.onViewCreated(view, savedInstanceState); - if (refreshLayout != null) setRefreshLayoutColors(refreshLayout); - } - - public static void setRefreshLayoutColors(SwipeRefreshLayout l) { - List colors = new ArrayList<>(Arrays.asList( - R.color.primary_600, - R.color.red_primary_600, - R.color.green_primary_600, - R.color.blue_primary_600, - R.color.purple_600 - )); - int primary = UiUtils.getThemeColorRes(l.getContext(), R.attr.colorPrimary600); - if (!colors.contains(primary)) colors.add(0, primary); - int offset = colors.indexOf(primary); - int[] sorted = new int[colors.size()]; - for (int i = 0; i < colors.size(); i++) { - sorted[i] = colors.get((i + offset) % colors.size()); - } - l.setColorSchemeResources(sorted); - } -} 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 064b8ea87..296c03b32 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/ScheduledStatusListFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/ScheduledStatusListFragment.java @@ -81,7 +81,7 @@ public class ScheduledStatusListFragment extends BaseStatusListFragment buildDisplayItems(ScheduledStatus s) { - return StatusDisplayItem.buildItems(this, s.toStatus(), accountID, s, knownAccounts, false, false, null, true, null); + return StatusDisplayItem.buildItems(this, s.toStatus(), accountID, s, knownAccounts, false, false, false, true, null); } @Override diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/SettingsFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/SettingsFragment.java deleted file mode 100644 index 0c9a06829..000000000 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/SettingsFragment.java +++ /dev/null @@ -1,1356 +0,0 @@ -package org.joinmastodon.android.fragments; - -import android.animation.ObjectAnimator; -import android.annotation.SuppressLint; -import android.app.Activity; -import android.content.Intent; -import android.graphics.Bitmap; -import android.graphics.Canvas; -import android.graphics.Rect; -import android.net.Uri; -import android.os.Build; -import android.os.Bundle; -import android.util.LruCache; -import android.util.TypedValue; -import android.view.Gravity; -import android.view.LayoutInflater; -import android.view.Menu; -import android.view.MenuItem; -import android.view.View; -import android.view.ViewGroup; -import android.view.WindowInsets; -import android.view.WindowManager; -import android.view.animation.LinearInterpolator; -import android.widget.Button; -import android.widget.ImageButton; -import android.widget.ImageView; -import android.widget.LinearLayout; -import android.widget.PopupMenu; -import android.widget.ProgressBar; -import android.widget.RadioButton; -import android.widget.Switch; -import android.widget.TextView; -import android.widget.Toast; - -import com.squareup.otto.Subscribe; - -import org.joinmastodon.android.BuildConfig; -import org.joinmastodon.android.E; -import org.joinmastodon.android.GlobalUserPreferences; -import org.joinmastodon.android.GlobalUserPreferences.AutoRevealMode; -import org.joinmastodon.android.GlobalUserPreferences.ColorPreference; -import org.joinmastodon.android.GlobalUserPreferences.PrefixRepliesMode; -import org.joinmastodon.android.MainActivity; -import org.joinmastodon.android.MastodonApp; -import org.joinmastodon.android.R; -import org.joinmastodon.android.api.MastodonAPIController; -import org.joinmastodon.android.api.PushSubscriptionManager; -import org.joinmastodon.android.api.requests.oauth.RevokeOauthToken; -import org.joinmastodon.android.api.session.AccountActivationInfo; -import org.joinmastodon.android.api.session.AccountSession; -import org.joinmastodon.android.api.session.AccountSessionManager; -import org.joinmastodon.android.events.SelfUpdateStateChangedEvent; -import org.joinmastodon.android.fragments.onboarding.InstanceRulesFragment; -import org.joinmastodon.android.model.ContentType; -import org.joinmastodon.android.model.Instance; -import org.joinmastodon.android.fragments.onboarding.AccountActivationFragment; -import org.joinmastodon.android.model.PushNotification; -import org.joinmastodon.android.model.PushSubscription; -import org.joinmastodon.android.ui.M3AlertDialogBuilder; -import org.joinmastodon.android.ui.OutlineProviders; -import org.joinmastodon.android.ui.utils.UiUtils; -import org.joinmastodon.android.ui.views.TextInputFrameLayout; -import org.joinmastodon.android.updater.GithubSelfUpdater; -import org.joinmastodon.android.utils.ProvidesAssistContent; -import org.parceler.Parcels; - -import java.util.ArrayList; -import java.util.Optional; -import java.util.function.Consumer; - -import androidx.annotation.DrawableRes; -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.annotation.StringRes; -import androidx.recyclerview.widget.LinearLayoutManager; -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.imageloader.ImageCache; -import me.grishka.appkit.utils.BindableViewHolder; -import me.grishka.appkit.utils.V; -import me.grishka.appkit.views.UsableRecyclerView; - -public class SettingsFragment extends MastodonToolbarFragment implements ProvidesAssistContent.ProvidesWebUri { - private UsableRecyclerView list; - private ArrayList items=new ArrayList<>(); - private ThemeItem themeItem; - private NotificationPolicyItem notificationPolicyItem; - private SwitchItem showNewPostsItem, glitchModeItem, compactReblogReplyLineItem, alwaysRevealSpoilersItem; - private ButtonItem defaultContentTypeButtonItem, autoRevealSpoilersItem; - private String accountID; - private boolean needUpdateNotificationSettings; - private boolean needAppRestart; - private PushSubscription pushSubscription; - - private ImageView themeTransitionWindowView; - private TextItem checkForUpdateItem, clearImageCacheItem; - private ImageCache imageCache; - private Menu contentTypeMenu; - - @SuppressLint("ClickableViewAccessibility") - @Override - public void onCreate(Bundle savedInstanceState){ - super.onCreate(savedInstanceState); - if(Build.VERSION.SDK_INT>=Build.VERSION_CODES.N) - setRetainInstance(true); - setTitle(R.string.settings); - imageCache = ImageCache.getInstance(getActivity()); - accountID=getArguments().getString("account"); - AccountSession session=AccountSessionManager.getInstance().getAccount(accountID); - Optional instance = session.getInstance(); - String instanceName = UiUtils.getInstanceName(accountID); - - if(GithubSelfUpdater.needSelfUpdating()){ - GithubSelfUpdater updater=GithubSelfUpdater.getInstance(); - GithubSelfUpdater.UpdateState state=updater.getState(); - if(state!=GithubSelfUpdater.UpdateState.NO_UPDATE && state!=GithubSelfUpdater.UpdateState.CHECKING){ - items.add(new UpdateItem()); - } - } - - items.add(new HeaderItem(R.string.settings_theme)); - items.add(themeItem=new ThemeItem()); - items.add(new SwitchItem(R.string.theme_true_black, R.drawable.ic_fluent_dark_theme_24_regular, GlobalUserPreferences.trueBlackTheme, this::onTrueBlackThemeChanged)); - items.add(new ButtonItem(R.string.sk_settings_color_palette, R.drawable.ic_fluent_color_24_regular, b->{ - PopupMenu popupMenu=new PopupMenu(getActivity(), b, Gravity.CENTER_HORIZONTAL); - popupMenu.inflate(R.menu.color_palettes); - popupMenu.getMenu().findItem(R.id.m3_color).setVisible(Build.VERSION.SDK_INT >= Build.VERSION_CODES.S); - popupMenu.setOnMenuItemClickListener(SettingsFragment.this::onColorPreferenceClick); - b.setOnTouchListener(popupMenu.getDragToOpenListener()); - b.setOnClickListener(v->popupMenu.show()); -// b.setText(switch(GlobalUserPreferences.color){ -// case MATERIAL3 -> R.string.sk_color_palette_material3; -// case PINK -> R.string.sk_color_palette_pink; -// case PURPLE -> R.string.sk_color_palette_purple; -// case GREEN -> R.string.sk_color_palette_green; -// case BLUE -> R.string.sk_color_palette_blue; -// case BROWN -> R.string.sk_color_palette_brown; -// case RED -> R.string.sk_color_palette_red; -// case YELLOW -> R.string.sk_color_palette_yellow; -// }); - })); - items.add(new ButtonItem(R.string.sk_settings_publish_button_text, R.drawable.ic_fluent_send_24_regular, b->{ - updatePublishText(b); - - b.setOnClickListener(l->{ - TextInputFrameLayout input = new TextInputFrameLayout( - getContext(), - getString(R.string.publish), - GlobalUserPreferences.publishButtonText.trim() - ); - new M3AlertDialogBuilder(getContext()).setTitle(R.string.sk_settings_publish_button_text_title).setView(input) - .setPositiveButton(R.string.save, (d, which) -> { - GlobalUserPreferences.publishButtonText = input.getEditText().getText().toString().trim(); - GlobalUserPreferences.save(); - updatePublishText(b); - }) - .setNeutralButton(R.string.clear, (d, which) -> { - GlobalUserPreferences.publishButtonText = ""; - GlobalUserPreferences.save(); - updatePublishText(b); - }) - .setNegativeButton(R.string.cancel, (d, which) -> {}) - .show(); - }); - })); - items.add(new SwitchItem(R.string.sk_settings_uniform_icon_for_notifications, R.drawable.ic_ntf_logo, GlobalUserPreferences.uniformNotificationIcon, i->{ - GlobalUserPreferences.uniformNotificationIcon=i.checked; - GlobalUserPreferences.save(); - })); - items.add(new SwitchItem(R.string.sk_disable_marquee, R.drawable.ic_fluent_text_more_24_regular, GlobalUserPreferences.disableMarquee, i->{ - GlobalUserPreferences.disableMarquee=i.checked; - GlobalUserPreferences.save(); - })); - items.add(new SwitchItem(R.string.sk_settings_reduce_motion, R.drawable.ic_fluent_star_emphasis_24_regular, GlobalUserPreferences.reduceMotion, i->{ - GlobalUserPreferences.reduceMotion=i.checked; - GlobalUserPreferences.save(); - })); - - items.add(new HeaderItem(R.string.settings_behavior)); - items.add(new SwitchItem(R.string.settings_gif, R.drawable.ic_fluent_gif_24_regular, GlobalUserPreferences.playGifs, i->{ - GlobalUserPreferences.playGifs=i.checked; - GlobalUserPreferences.save(); - })); - items.add(new SwitchItem(R.string.settings_custom_tabs, R.drawable.ic_fluent_link_24_regular, GlobalUserPreferences.useCustomTabs, i->{ - GlobalUserPreferences.useCustomTabs=i.checked; - GlobalUserPreferences.save(); - })); - items.add(new SwitchItem(R.string.sk_settings_show_interaction_counts, R.drawable.ic_fluent_number_row_24_regular, GlobalUserPreferences.showInteractionCounts, i->{ - GlobalUserPreferences.showInteractionCounts=i.checked; - GlobalUserPreferences.save(); - })); - items.add(alwaysRevealSpoilersItem = new SwitchItem(R.string.sk_settings_always_reveal_content_warnings, R.drawable.ic_fluent_chat_warning_24_regular, GlobalUserPreferences.alwaysExpandContentWarnings, i->{ - GlobalUserPreferences.alwaysExpandContentWarnings=i.checked; - GlobalUserPreferences.save(); - if (list.findViewHolderForAdapterPosition(items.indexOf(autoRevealSpoilersItem)) instanceof ButtonViewHolder bvh) bvh.rebind(); - })); - items.add(autoRevealSpoilersItem = new ButtonItem(R.string.sk_settings_auto_reveal_equal_spoilers, R.drawable.ic_fluent_eye_24_regular, b->{ - PopupMenu popupMenu=new PopupMenu(getActivity(), b, Gravity.CENTER_HORIZONTAL); - popupMenu.inflate(R.menu.settings_auto_reveal_spoiler); - popupMenu.setOnMenuItemClickListener(i -> onAutoRevealSpoilerClick(i, b)); - b.setOnTouchListener(popupMenu.getDragToOpenListener()); - b.setOnClickListener(v->popupMenu.show()); - onAutoRevealSpoilerChanged(b); - })); - items.add(new SwitchItem(R.string.sk_tabs_disable_swipe, R.drawable.ic_fluent_swipe_right_24_regular, GlobalUserPreferences.disableSwipe, i->{ - GlobalUserPreferences.disableSwipe=i.checked; - GlobalUserPreferences.save(); - needAppRestart=true; - })); - items.add(new SwitchItem(R.string.sk_enable_delete_notifications, R.drawable.ic_fluent_mail_inbox_dismiss_24_regular, GlobalUserPreferences.enableDeleteNotifications, i->{ - GlobalUserPreferences.enableDeleteNotifications=i.checked; - 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 ButtonItem(R.string.sk_settings_prefix_reply_cw_with_re, R.drawable.ic_fluent_arrow_reply_24_regular, b->{ - PopupMenu popupMenu=new PopupMenu(getActivity(), b, Gravity.CENTER_HORIZONTAL); - popupMenu.inflate(R.menu.settings_prefix_reply_mode); - popupMenu.setOnMenuItemClickListener(i -> onPrefixRepliesClick(i, b)); - b.setOnTouchListener(popupMenu.getDragToOpenListener()); - b.setOnClickListener(v->popupMenu.show()); - b.setText(switch(GlobalUserPreferences.prefixReplies){ - case TO_OTHERS -> R.string.sk_settings_prefix_replies_to_others; - case ALWAYS -> R.string.sk_settings_prefix_replies_always; - default -> R.string.sk_settings_prefix_replies_never; - }); - GlobalUserPreferences.save(); - })); - items.add(new SwitchItem(R.string.sk_settings_confirm_before_reblog, R.drawable.ic_fluent_checkmark_circle_24_regular, GlobalUserPreferences.confirmBeforeReblog, i->{ - GlobalUserPreferences.confirmBeforeReblog=i.checked; - GlobalUserPreferences.save(); - })); - items.add(new SwitchItem(R.string.sk_settings_forward_report_default, R.drawable.ic_fluent_arrow_forward_24_regular, GlobalUserPreferences.forwardReportDefault, i->{ - GlobalUserPreferences.forwardReportDefault=i.checked; - GlobalUserPreferences.save(); - })); - items.add(new SwitchItem(R.string.sk_settings_allow_remote_loading, R.drawable.ic_fluent_communication_24_regular, GlobalUserPreferences.allowRemoteLoading, i->{ - GlobalUserPreferences.allowRemoteLoading=i.checked; - GlobalUserPreferences.save(); - })); - items.add(new SmallTextItem(R.string.sk_settings_allow_remote_loading_explanation)); - - 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(); - })); - if (isInstanceAkkoma()) { - items.add(new ButtonItem(R.string.sk_settings_reply_visibility, R.drawable.ic_fluent_chat_24_regular, b->{ - PopupMenu popupMenu=new PopupMenu(getActivity(), b, Gravity.CENTER_HORIZONTAL); - popupMenu.inflate(R.menu.reply_visibility); - popupMenu.setOnMenuItemClickListener(item -> this.onReplyVisibilityChanged(item, b)); - b.setOnTouchListener(popupMenu.getDragToOpenListener()); - b.setOnClickListener(v->popupMenu.show()); - b.setText(GlobalUserPreferences.replyVisibility == null ? - R.string.sk_settings_reply_visibility_all : - switch(GlobalUserPreferences.replyVisibility){ - case "following" -> R.string.sk_settings_reply_visibility_following; - case "self" -> R.string.sk_settings_reply_visibility_self; - default -> R.string.sk_settings_reply_visibility_all; - }); - })); - } - 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; - showNewPostsItem.enabled = i.checked; - if (!i.checked) { - GlobalUserPreferences.showNewPostsButton = false; - showNewPostsItem.checked = false; - } - if (list.findViewHolderForAdapterPosition(items.indexOf(showNewPostsItem)) instanceof SwitchViewHolder svh) svh.rebind(); - GlobalUserPreferences.save(); - })); - items.add(showNewPostsItem = new SwitchItem(R.string.sk_settings_show_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_regular, GlobalUserPreferences.collapseLongPosts, i->{ - GlobalUserPreferences.collapseLongPosts=i.checked; - GlobalUserPreferences.save(); - })); - items.add(new SwitchItem(R.string.sk_settings_hide_interaction, R.drawable.ic_fluent_star_off_24_regular, GlobalUserPreferences.spectatorMode, i->{ - GlobalUserPreferences.spectatorMode=i.checked; - GlobalUserPreferences.save(); - needAppRestart=true; - })); - items.add(new SwitchItem(R.string.sk_settings_hide_fab, R.drawable.ic_fluent_edit_24_regular, GlobalUserPreferences.autoHideFab, i->{ - GlobalUserPreferences.autoHideFab=i.checked; - GlobalUserPreferences.save(); - needAppRestart=true; - })); - items.add(new SwitchItem(R.string.sk_reply_line_above_avatar, R.drawable.ic_fluent_arrow_reply_24_regular, GlobalUserPreferences.replyLineAboveHeader, i->{ - GlobalUserPreferences.replyLineAboveHeader=i.checked; - GlobalUserPreferences.compactReblogReplyLine=i.checked; - compactReblogReplyLineItem.enabled=i.checked; - compactReblogReplyLineItem.checked= GlobalUserPreferences.replyLineAboveHeader; - if (list.findViewHolderForAdapterPosition(items.indexOf(compactReblogReplyLineItem)) instanceof SwitchViewHolder svh) svh.rebind(); - GlobalUserPreferences.save(); - needAppRestart=true; - })); - items.add(compactReblogReplyLineItem=new SwitchItem(R.string.sk_compact_reblog_reply_line, R.drawable.ic_fluent_re_order_24_regular, GlobalUserPreferences.compactReblogReplyLine, i->{ - GlobalUserPreferences.compactReblogReplyLine=i.checked; - GlobalUserPreferences.save(); - needAppRestart=true; - })); - compactReblogReplyLineItem.enabled=GlobalUserPreferences.replyLineAboveHeader; -// 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(); -// needAppRestart=true; -// })); - boolean translationAvailable = instance - .map(i -> i.v2 != null && i.v2.configuration.translation != null && i.v2.configuration.translation.enabled) - .orElse(false); - items.add(new SmallTextItem(getString(translationAvailable ? - R.string.sk_settings_translation_availability_note_available : - R.string.sk_settings_translation_availability_note_unavailable, instanceName))); - - items.add(new HeaderItem(R.string.settings_notifications)); - items.add(notificationPolicyItem=new NotificationPolicyItem()); - PushSubscription pushSubscription=getPushSubscription(); - 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)); - items.add(new TextItem(R.string.sk_settings_posting, ()->UiUtils.launchWebBrowser(getActivity(), "https://"+session.domain+"/settings/preferences/other"), R.drawable.ic_fluent_open_24_regular)); - items.add(new TextItem(R.string.sk_settings_filters, ()->UiUtils.launchWebBrowser(getActivity(), "https://"+session.domain+"/filters"), R.drawable.ic_fluent_open_24_regular)); - items.add(new TextItem(R.string.sk_settings_auth, ()->UiUtils.launchWebBrowser(getActivity(), "https://"+session.domain+"/auth/edit"), R.drawable.ic_fluent_open_24_regular)); - - items.add(new HeaderItem(instanceName)); - items.add(new TextItem(R.string.sk_settings_rules, instance.map(i -> () -> { - Bundle args = new Bundle(); - args.putParcelable("instance", Parcels.wrap(i)); - Nav.go(getActivity(), InstanceRulesFragment.class, args); - }).orElse(null), R.drawable.ic_fluent_task_list_ltr_24_regular)); - items.add(new TextItem(R.string.sk_settings_about_instance , ()->UiUtils.launchWebBrowser(getActivity(), "https://"+session.domain+"/about"), R.drawable.ic_fluent_info_24_regular)); - 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)); - items.add(new SmallTextItem(instance - .map(i -> getString(R.string.sk_settings_server_version, i.version)) - .orElse(getString(R.string.sk_instance_info_unavailable)))); - - items.add(new HeaderItem(R.string.sk_instance_features)); - items.add(new SwitchItem(R.string.sk_settings_content_types, 0, GlobalUserPreferences.accountsWithContentTypesEnabled.contains(accountID), (i)->{ - if (i.checked) { - GlobalUserPreferences.accountsWithContentTypesEnabled.add(accountID); - if (GlobalUserPreferences.accountsDefaultContentTypes.get(accountID) == null) { - GlobalUserPreferences.accountsDefaultContentTypes.put(accountID, ContentType.PLAIN); - } - } else { - GlobalUserPreferences.accountsWithContentTypesEnabled.remove(accountID); - GlobalUserPreferences.accountsDefaultContentTypes.remove(accountID); - } - if (list.findViewHolderForAdapterPosition(items.indexOf(defaultContentTypeButtonItem)) - instanceof ButtonViewHolder bvh) bvh.rebind(); - GlobalUserPreferences.save(); - })); - items.add(new SmallTextItem(getString(R.string.sk_settings_content_types_explanation))); - items.add(defaultContentTypeButtonItem = new ButtonItem(R.string.sk_settings_default_content_type, 0, b->{ - PopupMenu popupMenu=new PopupMenu(getActivity(), b, Gravity.CENTER_HORIZONTAL); - popupMenu.inflate(R.menu.compose_content_type); - popupMenu.setOnMenuItemClickListener(item -> this.onContentTypeChanged(item, b)); - b.setOnTouchListener(popupMenu.getDragToOpenListener()); - b.setOnClickListener(v->popupMenu.show()); - ContentType contentType = GlobalUserPreferences.accountsDefaultContentTypes.get(accountID); - b.setText(getContentTypeString(contentType)); - contentTypeMenu = popupMenu.getMenu(); - contentTypeMenu.findItem(ContentType.getContentTypeRes(contentType)).setChecked(true); - instance.ifPresent(i -> ContentType.adaptMenuToInstance(contentTypeMenu, i)); - })); - items.add(new SmallTextItem(getString(R.string.sk_settings_default_content_type_explanation))); - 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 (!isInstanceAkkoma()) { - 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)); - 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))); - } - - private void updatePublishText(Button btn) { - if (GlobalUserPreferences.publishButtonText.isBlank()) btn.setText(R.string.publish); - else btn.setText(GlobalUserPreferences.publishButtonText); - } - - @Override - public void onAttach(Activity activity){ - super.onAttach(activity); - if(themeTransitionWindowView!=null){ - // Activity has finished recreating. Remove the overlay. - MastodonApp.context.getSystemService(WindowManager.class).removeView(themeTransitionWindowView); - themeTransitionWindowView=null; - } - } - - @Override - public View onCreateContentView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState){ - list=new UsableRecyclerView(getActivity()); - list.setLayoutManager(new LinearLayoutManager(getActivity())); - list.setAdapter(new SettingsAdapter()); - list.setBackgroundColor(UiUtils.getThemeColor(getActivity(), android.R.attr.colorBackground)); - list.setPadding(0, V.dp(16), 0, V.dp(12)); - list.setClipToPadding(false); - list.addItemDecoration(new RecyclerView.ItemDecoration(){ - @Override - public void getItemOffsets(@NonNull Rect outRect, @NonNull View view, @NonNull RecyclerView parent, @NonNull RecyclerView.State state){ - // Add 32dp gaps between sections - RecyclerView.ViewHolder holder=parent.getChildViewHolder(view); - if((holder instanceof HeaderViewHolder || holder instanceof FooterViewHolder) && holder.getAbsoluteAdapterPosition()>1) - outRect.top=V.dp(32); - } - }); - return list; - } - - @Override - public void onApplyWindowInsets(WindowInsets insets){ - if(Build.VERSION.SDK_INT>=29 && insets.getTappableElementInsets().bottom==0){ - list.setPadding(0, V.dp(16), 0, V.dp(12)+insets.getSystemWindowInsetBottom()); - insets=insets.inset(0, 0, 0, insets.getSystemWindowInsetBottom()); - } - super.onApplyWindowInsets(insets); - } - - @Override - public void onDestroy(){ - super.onDestroy(); - if(needUpdateNotificationSettings && PushSubscriptionManager.arePushNotificationsAvailable()){ - AccountSessionManager.getInstance().getAccount(accountID).getPushSubscriptionManager().updatePushSettings(pushSubscription); - } - if(needAppRestart) UiUtils.restartApp(); - } - - @Override - public void onViewCreated(View view, Bundle savedInstanceState){ - super.onViewCreated(view, savedInstanceState); - if(GithubSelfUpdater.needSelfUpdating()) - E.register(this); - } - - @Override - public void onDestroyView(){ - super.onDestroyView(); - if(GithubSelfUpdater.needSelfUpdating()) - E.unregister(this); - } - - private void onThemePreferenceClick(GlobalUserPreferences.ThemePreference theme){ - GlobalUserPreferences.theme=theme; - GlobalUserPreferences.save(); - restartActivityToApplyNewTheme(); - } - - private boolean onColorPreferenceClick(MenuItem item){ - ColorPreference pref = null; - int id = item.getItemId(); - - if (id == R.id.m3_color) pref = ColorPreference.MATERIAL3; - else if (id == R.id.pink_color) pref = ColorPreference.PINK; - else if (id == R.id.purple_color) pref = ColorPreference.PURPLE; - else if (id == R.id.green_color) pref = ColorPreference.GREEN; - else if (id == R.id.blue_color) pref = ColorPreference.BLUE; - else if (id == R.id.brown_color) pref = ColorPreference.BROWN; - else if (id == R.id.red_color) pref = ColorPreference.RED; - else if (id == R.id.yellow_color) pref = ColorPreference.YELLOW; - - if (pref == null) return false; - - GlobalUserPreferences.color=pref; - GlobalUserPreferences.save(); - restartActivityToApplyNewTheme(); - return true; - } - - private boolean onPrefixRepliesClick(MenuItem item, Button btn) { - int id = item.getItemId(); - PrefixRepliesMode mode = PrefixRepliesMode.NEVER; - if (id == R.id.prefix_replies_always) mode = PrefixRepliesMode.ALWAYS; - else if (id == R.id.prefix_replies_to_others) mode = PrefixRepliesMode.TO_OTHERS; - GlobalUserPreferences.prefixReplies = mode; - - btn.setText(switch(GlobalUserPreferences.prefixReplies){ - case TO_OTHERS -> R.string.sk_settings_prefix_replies_to_others; - case ALWAYS -> R.string.sk_settings_prefix_replies_always; - default -> R.string.sk_settings_prefix_replies_never; - }); - - return true; - } - - private boolean onAutoRevealSpoilerClick(MenuItem item, Button btn) { - int id = item.getItemId(); - - AutoRevealMode mode = AutoRevealMode.NEVER; - if (id == R.id.auto_reveal_threads) mode = AutoRevealMode.THREADS; - else if (id == R.id.auto_reveal_discussions) mode = AutoRevealMode.DISCUSSIONS; - - GlobalUserPreferences.alwaysExpandContentWarnings = false; - GlobalUserPreferences.autoRevealEqualSpoilers = mode; - GlobalUserPreferences.save(); - onAutoRevealSpoilerChanged(btn); - return true; - } - - private void onAutoRevealSpoilerChanged(Button b) { - if (GlobalUserPreferences.alwaysExpandContentWarnings) { - b.setText(R.string.sk_settings_auto_reveal_anyone); - } else { - b.setText(switch(GlobalUserPreferences.autoRevealEqualSpoilers){ - case THREADS -> R.string.sk_settings_auto_reveal_author; - case DISCUSSIONS -> R.string.sk_settings_auto_reveal_anyone; - default -> R.string.sk_settings_auto_reveal_nobody; - }); - if (alwaysRevealSpoilersItem.checked != GlobalUserPreferences.alwaysExpandContentWarnings) { - alwaysRevealSpoilersItem.checked = GlobalUserPreferences.alwaysExpandContentWarnings; - if (list.findViewHolderForAdapterPosition(items.indexOf(alwaysRevealSpoilersItem)) instanceof SwitchViewHolder svh) svh.rebind(); - } - } - } - - private void onTrueBlackThemeChanged(SwitchItem item){ - GlobalUserPreferences.trueBlackTheme=item.checked; - GlobalUserPreferences.save(); - - RecyclerView.ViewHolder themeHolder=list.findViewHolderForAdapterPosition(items.indexOf(themeItem)); - if(themeHolder!=null){ - ((ThemeViewHolder)themeHolder).bindSubitems(); - }else{ - list.getAdapter().notifyItemChanged(items.indexOf(themeItem)); - } - - if(UiUtils.isDarkTheme()){ - restartActivityToApplyNewTheme(); - } - } - - private @StringRes int getContentTypeString(@Nullable ContentType contentType) { - if (contentType == null) return R.string.sk_content_type_unspecified; - return switch (contentType) { - case PLAIN -> R.string.sk_content_type_plain; - case HTML -> R.string.sk_content_type_html; - case MARKDOWN -> R.string.sk_content_type_markdown; - case BBCODE -> R.string.sk_content_type_bbcode; - case MISSKEY_MARKDOWN -> R.string.sk_content_type_mfm; - }; - } - - private boolean onContentTypeChanged(MenuItem item, Button btn){ - int id = item.getItemId(); - - ContentType contentType = null; - if (id == R.id.content_type_plain) contentType = ContentType.PLAIN; - else if (id == R.id.content_type_html) contentType = ContentType.HTML; - else if (id == R.id.content_type_markdown) contentType = ContentType.MARKDOWN; - else if (id == R.id.content_type_bbcode) contentType = ContentType.BBCODE; - else if (id == R.id.content_type_misskey_markdown) contentType = ContentType.MISSKEY_MARKDOWN; - - GlobalUserPreferences.accountsDefaultContentTypes.put(accountID, contentType); - GlobalUserPreferences.save(); - btn.setText(getContentTypeString(contentType)); - item.setChecked(true); - return true; - } - - private boolean onReplyVisibilityChanged(MenuItem item, Button btn){ - String pref = null; - int id = item.getItemId(); - - if (id == R.id.reply_visibility_following) pref = "following"; - else if (id == R.id.reply_visibility_self) pref = "self"; - - GlobalUserPreferences.replyVisibility=pref; - GlobalUserPreferences.save(); - btn.setText(GlobalUserPreferences.replyVisibility == null ? - R.string.sk_settings_reply_visibility_all : - switch(GlobalUserPreferences.replyVisibility){ - case "following" -> R.string.sk_settings_reply_visibility_following; - case "self" -> R.string.sk_settings_reply_visibility_self; - default -> R.string.sk_settings_reply_visibility_all; - }); - return true; - } - - private void restartActivityToApplyNewTheme(){ - // Calling activity.recreate() causes a black screen for like half a second. - // So, let's take a screenshot and overlay it on top to create the illusion of a smoother transition. - // As a bonus, we can fade it out to make it even smoother. - if(Build.VERSION.SDK_INT>=Build.VERSION_CODES.N){ - View activityDecorView=getActivity().getWindow().getDecorView(); - Bitmap bitmap=Bitmap.createBitmap(activityDecorView.getWidth(), activityDecorView.getHeight(), Bitmap.Config.ARGB_8888); - activityDecorView.draw(new Canvas(bitmap)); - themeTransitionWindowView=new ImageView(MastodonApp.context); - themeTransitionWindowView.setImageBitmap(bitmap); - WindowManager.LayoutParams lp=new WindowManager.LayoutParams(WindowManager.LayoutParams.TYPE_APPLICATION); - lp.flags=WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE | WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE | - WindowManager.LayoutParams.FLAG_LAYOUT_INSET_DECOR | WindowManager.LayoutParams.FLAG_LAYOUT_IN_SCREEN | WindowManager.LayoutParams.FLAG_DRAWS_SYSTEM_BAR_BACKGROUNDS; - lp.systemUiVisibility=View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN | View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION | View.SYSTEM_UI_FLAG_LAYOUT_STABLE; - lp.systemUiVisibility|=(activityDecorView.getWindowSystemUiVisibility() & (View.SYSTEM_UI_FLAG_LIGHT_NAVIGATION_BAR | View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR)); - lp.width=lp.height=WindowManager.LayoutParams.MATCH_PARENT; - lp.token=getActivity().getWindow().getAttributes().token; - lp.windowAnimations=R.style.window_fade_out; - MastodonApp.context.getSystemService(WindowManager.class).addView(themeTransitionWindowView, lp); - } - getActivity().recreate(); - } - - private PushSubscription getPushSubscription(){ - if(pushSubscription!=null) - return pushSubscription; - AccountSession session=AccountSessionManager.getInstance().getAccount(accountID); - if(session.pushSubscription==null){ - pushSubscription=new PushSubscription(); - pushSubscription.alerts=PushSubscription.Alerts.ofAll(); - }else{ - pushSubscription=session.pushSubscription.clone(); - } - return pushSubscription; - } - - private void onNotificationsChanged(PushNotification.Type type, boolean enabled){ - PushSubscription subscription=getPushSubscription(); - switch(type){ - case FAVORITE -> subscription.alerts.favourite=enabled; - case FOLLOW -> subscription.alerts.follow=enabled; - case REBLOG -> subscription.alerts.reblog=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; - } - - private void onNotificationsPolicyChanged(PushSubscription.Policy policy){ - PushSubscription subscription=getPushSubscription(); - PushSubscription.Policy prevPolicy=subscription.policy; - if(prevPolicy==policy) - return; - subscription.policy=policy; - int index=items.indexOf(notificationPolicyItem); - RecyclerView.ViewHolder policyHolder=list.findViewHolderForAdapterPosition(index); - if(policyHolder!=null){ - ((NotificationPolicyViewHolder)policyHolder).rebind(); - }else{ - 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=newState; - RecyclerView.ViewHolder holder=list.findViewHolderForAdapterPosition(index); - if(holder!=null) - ((BindableViewHolder)holder).rebind(); - else - list.getAdapter().notifyItemChanged(index); - index++; - } - } - needUpdateNotificationSettings=true; - } - - private void confirmLogOut(){ - new M3AlertDialogBuilder(getActivity()) - .setTitle(R.string.log_out) - .setMessage(R.string.confirm_log_out) - .setPositiveButton(R.string.log_out, (dialog, which) -> logOut()) - .setNegativeButton(R.string.cancel, null) - .show(); - } - - private void logOut(){ - AccountSession session=AccountSessionManager.getInstance().getAccount(accountID); - new RevokeOauthToken(session.app.clientId, session.app.clientSecret, session.token.accessToken) - .setCallback(new Callback<>(){ - @Override - public void onSuccess(Object result){ - onLoggedOut(); - } - - @Override - public void onError(ErrorResponse error){ - onLoggedOut(); - } - }) - .wrapProgress(getActivity(), R.string.loading, false) - .exec(accountID); - } - - private void onLoggedOut(){ - if (getActivity() == null) return; - AccountSessionManager.getInstance().removeAccount(accountID); - getActivity().finish(); - Intent intent=new Intent(getActivity(), MainActivity.class); - startActivity(intent); - } - - private void clearImageCache(){ - MastodonAPIController.runInBackground(()->{ - Activity activity=getActivity(); - imageCache.clear(); - Toast.makeText(activity, R.string.media_cache_cleared, Toast.LENGTH_SHORT).show(); - }); - if (list.findViewHolderForAdapterPosition(items.indexOf(clearImageCacheItem)) instanceof TextViewHolder tvh) { - clearImageCacheItem.secondaryText = UiUtils.formatFileSize(getContext(), 0, true); - tvh.rebind(); - } - } - - @Subscribe - public void onSelfUpdateStateChanged(SelfUpdateStateChangedEvent ev){ - checkForUpdateItem.loading = ev.state == GithubSelfUpdater.UpdateState.CHECKING; - if (list.findViewHolderForAdapterPosition(items.indexOf(checkForUpdateItem)) instanceof TextViewHolder tvh) tvh.rebind(); - - UpdateItem updateItem = null; - if(items.get(0) instanceof UpdateItem item0) { - updateItem = item0; - } else if (ev.state != GithubSelfUpdater.UpdateState.CHECKING - && ev.state != GithubSelfUpdater.UpdateState.NO_UPDATE) { - updateItem = new UpdateItem(); - items.add(0, updateItem); - list.setAdapter(new SettingsAdapter()); - } - - if(updateItem != null && list.findViewHolderForAdapterPosition(0) instanceof UpdateViewHolder uvh){ - uvh.bind(updateItem); - } - - if (ev.state == GithubSelfUpdater.UpdateState.NO_UPDATE) { - Toast.makeText(getActivity(), R.string.sk_no_update_available, Toast.LENGTH_SHORT).show(); - } - } - - @Override - public Uri getWebUri(Uri.Builder base) { - return base.path(isInstanceAkkoma() ? "/about" : "/settings").build(); - } - - @Override - public String getAccountID() { - return accountID; - } - - private static abstract class Item{ - public abstract int getViewType(); - } - - private class HeaderItem extends Item{ - private String text; - - public HeaderItem(@StringRes int text){ - this.text=getString(text); - } - - public HeaderItem(String text){ - this.text=text; - } - - @Override - public int getViewType(){ - return 0; - } - } - - private class SwitchItem extends Item{ - private String text; - private int icon; - private boolean checked; - private Consumer onChanged; - private boolean enabled=true; - - public SwitchItem(@StringRes int text, @DrawableRes int icon, boolean checked, Consumer onChanged){ - this.text=getString(text); - this.icon=icon; - this.checked=checked; - this.onChanged=onChanged; - } - - public SwitchItem(@StringRes int text, @DrawableRes int icon, boolean checked, Consumer onChanged, boolean enabled){ - this.text=getString(text); - this.icon=icon; - this.checked=checked; - this.onChanged=onChanged; - this.enabled=enabled; - } - - @Override - public int getViewType(){ - return 1; - } - } - - public class ButtonItem extends Item{ - private int text; - private int icon; - private Consumer