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

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

View File

@@ -169,7 +169,8 @@ 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);
@@ -265,7 +266,7 @@ public class AudioPlayerService extends Service{
private void updateNotification(boolean dismissable, boolean removeNotification){
Notification.Builder bldr=new Notification.Builder(this)
.setSmallIcon(R.drawable.ic_ntf_logo)
.setContentTitle(status.account.displayName)
.setContentTitle(status.account.getDisplayName())
.setContentText(HtmlParser.strip(status.content))
.setOngoing(!dismissable)
.setShowWhen(false)
@@ -280,7 +281,7 @@ public class AudioPlayerService extends Service{
if(playerReady){
boolean isPlaying=player.isPlaying();
bldr.addAction(new Notification.Action.Builder(Icon.createWithResource(this, isPlaying ? R.drawable.ic_pause_24 : R.drawable.ic_play_24),
bldr.addAction(new Notification.Action.Builder(Icon.createWithResource(this, isPlaying ? R.drawable.ic_fluent_pause_24_filled : R.drawable.ic_fluent_play_24_filled),
getString(isPlaying ? R.string.pause : R.string.play),
PendingIntent.getBroadcast(this, 2, new Intent(ACTION_PLAY_PAUSE), PendingIntent.FLAG_IMMUTABLE))
.build());

View File

@@ -0,0 +1,24 @@
package org.joinmastodon.android;
import android.app.Activity;
import android.content.Context;
import android.content.Intent;
import android.os.Bundle;
public class ExitActivity extends Activity {
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
finishAndRemoveTask();
}
public static void exit(Context context) {
Intent intent = new Intent(context, ExitActivity.class);
intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP);
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK);
context.startActivity(intent);
}
}

View File

@@ -3,21 +3,25 @@ package org.joinmastodon.android;
import android.app.Fragment;
import android.content.ClipData;
import android.content.Intent;
import android.graphics.drawable.ColorDrawable;
import android.net.Uri;
import android.os.Bundle;
import android.text.TextUtils;
import android.util.Pair;
import android.widget.Toast;
import org.joinmastodon.android.api.MastodonAPIRequest;
import org.joinmastodon.android.api.session.AccountSession;
import org.joinmastodon.android.api.session.AccountSessionManager;
import org.joinmastodon.android.fragments.ComposeFragment;
import org.joinmastodon.android.ui.M3AlertDialogBuilder;
import org.joinmastodon.android.ui.AccountSwitcherSheet;
import org.joinmastodon.android.ui.utils.UiUtils;
import org.jsoup.internal.StringUtil;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Optional;
import java.util.function.BiConsumer;
import androidx.annotation.Nullable;
import me.grishka.appkit.FragmentStackActivity;
@@ -28,21 +32,51 @@ public class ExternalShareActivity extends FragmentStackActivity{
UiUtils.setUserPreferredTheme(this);
super.onCreate(savedInstanceState);
if(savedInstanceState==null){
Optional<String> text = Optional.ofNullable(getIntent().getStringExtra(Intent.EXTRA_TEXT));
Optional<Pair<String, Optional<String>>> fediHandle = text.flatMap(UiUtils::parseFediverseHandle);
boolean isFediUrl = text.map(UiUtils::looksLikeFediverseUrl).orElse(false);
boolean isOpenable = isFediUrl || fediHandle.isPresent();
List<AccountSession> sessions=AccountSessionManager.getInstance().getLoggedInAccounts();
if(sessions.isEmpty()){
if (sessions.isEmpty()){
Toast.makeText(this, R.string.err_not_logged_in, Toast.LENGTH_SHORT).show();
finish();
}else if(sessions.size()==1){
} else if (isOpenable || sessions.size() > 1) {
AccountSwitcherSheet sheet = new AccountSwitcherSheet(this, null, true, isOpenable);
sheet.setOnClick((accountId, open) -> {
if (open && text.isPresent()) {
BiConsumer<Class<? extends Fragment>, Bundle> callback = (clazz, args) -> {
if (clazz == null) {
Toast.makeText(this, R.string.sk_open_in_app_failed, Toast.LENGTH_SHORT).show();
// TODO: do something about the window getting leaked
sheet.dismiss();
finish();
return;
}
args.putString("fromExternalShare", clazz.getSimpleName());
Intent intent = new Intent(this, MainActivity.class);
intent.putExtras(args);
finish();
startActivity(intent);
};
fediHandle
.<MastodonAPIRequest<?>>map(handle ->
UiUtils.lookupAccountHandle(this, accountId, handle, callback))
.or(() ->
UiUtils.lookupURL(this, accountId, text.get(), callback))
.ifPresent(req ->
req.wrapProgress(this, R.string.loading, true, d -> {
UiUtils.transformDialogForLookup(this, accountId, isFediUrl ? text.get() : null, d);
d.setOnDismissListener((x) -> finish());
}));
} else {
openComposeFragment(accountId);
}
});
sheet.show();
} else if (sessions.size() == 1) {
openComposeFragment(sessions.get(0).getID());
}else{
getWindow().setBackgroundDrawable(new ColorDrawable(0xff000000));
new M3AlertDialogBuilder(this)
.setItems(sessions.stream().map(as->"@"+as.self.username+"@"+as.domain).toArray(String[]::new), (dialog, which)->{
openComposeFragment(sessions.get(which).getID());
})
.setTitle(R.string.choose_account)
.setOnCancelListener(dialog -> finish())
.show();
}
}
}
@@ -51,7 +85,20 @@ public class ExternalShareActivity extends FragmentStackActivity{
getWindow().setBackgroundDrawable(null);
Intent intent=getIntent();
String text=intent.getStringExtra(Intent.EXTRA_TEXT);
StringBuilder builder=new StringBuilder();
String subject = "";
if (intent.hasExtra(Intent.EXTRA_SUBJECT)) {
subject = intent.getStringExtra(Intent.EXTRA_SUBJECT);
if (!StringUtil.isBlank(subject)) builder.append(subject).append("\n\n");
}
if (intent.hasExtra(Intent.EXTRA_TEXT)) {
String extra = intent.getStringExtra(Intent.EXTRA_TEXT);
if (!StringUtil.isBlank(extra)) {
if (extra.startsWith(subject)) extra = extra.substring(subject.length()).trim();
builder.append(extra).append("\n\n");
}
}
String text=builder.toString();
List<Uri> mediaUris;
if(Intent.ACTION_SEND.equals(intent.getAction())){
Uri singleUri=intent.getParcelableExtra(Intent.EXTRA_STREAM);
@@ -77,6 +124,7 @@ public class ExternalShareActivity extends FragmentStackActivity{
args.putString("account", accountID);
if(!TextUtils.isEmpty(text))
args.putString("prefilledText", text);
args.putInt("selectionStart", StringUtil.isBlank(subject) ? 0 : subject.length());
if(mediaUris!=null && !mediaUris.isEmpty())
args.putParcelableArrayList("mediaAttachments", toArrayList(mediaUris));
Fragment fragment=new ComposeFragment();

View File

@@ -1,34 +1,182 @@
package org.joinmastodon.android;
import static org.joinmastodon.android.api.MastodonAPIController.gson;
import static org.joinmastodon.android.api.session.AccountLocalPreferences.ColorPreference.MATERIAL3;
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.AccountLocalPreferences.ColorPreference;
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 java.lang.reflect.Type;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
import java.util.Set;
import org.joinmastodon.android.api.session.AccountSessionManager;
import org.joinmastodon.android.model.Account;
public class GlobalUserPreferences{
private static final String TAG="GlobalUserPreferences";
public static boolean playGifs;
public static boolean useCustomTabs;
public static boolean altTextReminders, confirmUnfollow, confirmBoost, confirmDeletePost;
public static ThemePreference theme;
private static SharedPreferences getPrefs(){
// MEGALODON
public static boolean trueBlackTheme;
public static boolean loadNewPosts;
public static boolean showNewPostsButton;
public static boolean toolbarMarquee;
public static boolean disableSwipe;
public static boolean enableDeleteNotifications;
public static boolean translateButtonOpenedOnly;
public static boolean uniformNotificationIcon;
public static boolean reduceMotion;
public static boolean showAltIndicator;
public static boolean showNoAltIndicator;
public static boolean enablePreReleases;
public static PrefixRepliesMode prefixReplies;
public static boolean collapseLongPosts;
public static boolean spectatorMode;
public static boolean autoHideFab;
public static boolean allowRemoteLoading;
public static boolean forwardReportDefault;
public static AutoRevealMode autoRevealEqualSpoilers;
public static boolean disableM3PillActiveIndicator;
public static boolean showNavigationLabels;
public static boolean displayPronounsInTimelines, displayPronounsInThreads, displayPronounsInUserListings;
public static boolean overlayMedia;
public static boolean showSuicideHelp;
public static boolean underlinedLinks;
public static ColorPreference color;
public static boolean likeIcon;
// MOSHIDON
public static boolean showDividers;
public static boolean relocatePublishButton;
public static boolean defaultToUnlistedReplies;
public static boolean doubleTapToSearch;
public static boolean doubleTapToSwipe;
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 showPostsWithoutAlt;
public static boolean showMediaPreview;
public static SharedPreferences getPrefs(){
return MastodonApp.context.getSharedPreferences("global", Context.MODE_PRIVATE);
}
private static SharedPreferences getPreReplyPrefs(){
return MastodonApp.context.getSharedPreferences("pre_reply_sheets", Context.MODE_PRIVATE);
public static <T> 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 <T extends Enum<T>> T enumValue(Class<T> 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);
altTextReminders=prefs.getBoolean("altTextReminders", false);
confirmUnfollow=prefs.getBoolean("confirmUnfollow", false);
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);
loadNewPosts=prefs.getBoolean("loadNewPosts", true);
showNewPostsButton=prefs.getBoolean("showNewPostsButton", true);
toolbarMarquee=prefs.getBoolean("toolbarMarquee", true);
disableSwipe=prefs.getBoolean("disableSwipe", false);
enableDeleteNotifications=prefs.getBoolean("enableDeleteNotifications", false);
translateButtonOpenedOnly=prefs.getBoolean("translateButtonOpenedOnly", false);
uniformNotificationIcon=prefs.getBoolean("uniformNotificationIcon", false);
reduceMotion=prefs.getBoolean("reduceMotion", 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()));
collapseLongPosts=prefs.getBoolean("collapseLongPosts", true);
spectatorMode=prefs.getBoolean("spectatorMode", false);
autoHideFab=prefs.getBoolean("autoHideFab", 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);
showSuicideHelp=prefs.getBoolean("showSuicideHelp", true);
underlinedLinks=prefs.getBoolean("underlinedLinks", true);
color=ColorPreference.valueOf(prefs.getString("color", MATERIAL3.name()));
likeIcon=prefs.getBoolean("likeIcon", false);
// MOSHIDON
uniformNotificationIcon=prefs.getBoolean("uniformNotificationIcon", false);
showDividers =prefs.getBoolean("showDividers", false);
relocatePublishButton=prefs.getBoolean("relocatePublishButton", true);
defaultToUnlistedReplies=prefs.getBoolean("defaultToUnlistedReplies", false);
doubleTapToSearch =prefs.getBoolean("doubleTapToSearch", true);
doubleTapToSwipe =prefs.getBoolean("doubleTapToSwipe", true);
replyLineAboveHeader=prefs.getBoolean("replyLineAboveHeader", true);
confirmBeforeReblog=prefs.getBoolean("confirmBeforeReblog", false);
hapticFeedback=prefs.getBoolean("hapticFeedback", true);
swapBookmarkWithBoostAction=prefs.getBoolean("swapBookmarkWithBoostAction", false);
loadRemoteAccountFollowers=prefs.getBoolean("loadRemoteAccountFollowers", true);
mentionRebloggerAutomatically=prefs.getBoolean("mentionRebloggerAutomatically", false);
showPostsWithoutAlt=prefs.getBoolean("showPostsWithoutAlt", true);
showMediaPreview=prefs.getBoolean("showMediaPreview", true);
theme=ThemePreference.values()[prefs.getInt("theme", 0)];
if (prefs.contains("prefixRepliesWithRe")) {
prefixReplies = prefs.getBoolean("prefixRepliesWithRe", false)
? PrefixRepliesMode.TO_OTHERS : PrefixRepliesMode.NEVER;
prefs.edit()
.putString("prefixReplies", prefixReplies.name())
.remove("prefixRepliesWithRe")
.apply();
}
int migrationLevel=prefs.getInt("migrationLevel", BuildConfig.VERSION_CODE);
if(migrationLevel < 61)
migrateToUpstreamVersion61();
if(migrationLevel < BuildConfig.VERSION_CODE)
prefs.edit().putInt("migrationLevel", BuildConfig.VERSION_CODE).apply();
}
public static void save(){
@@ -40,45 +188,126 @@ public class GlobalUserPreferences{
.putBoolean("confirmUnfollow", confirmUnfollow)
.putBoolean("confirmBoost", confirmBoost)
.putBoolean("confirmDeletePost", confirmDeletePost)
// MEGALODON
.putBoolean("loadNewPosts", loadNewPosts)
.putBoolean("showNewPostsButton", showNewPostsButton)
.putBoolean("trueBlackTheme", trueBlackTheme)
.putBoolean("toolbarMarquee", toolbarMarquee)
.putBoolean("disableSwipe", disableSwipe)
.putBoolean("enableDeleteNotifications", enableDeleteNotifications)
.putBoolean("translateButtonOpenedOnly", translateButtonOpenedOnly)
.putBoolean("uniformNotificationIcon", uniformNotificationIcon)
.putBoolean("reduceMotion", reduceMotion)
.putBoolean("showAltIndicator", showAltIndicator)
.putBoolean("showNoAltIndicator", showNoAltIndicator)
.putBoolean("enablePreReleases", enablePreReleases)
.putString("prefixReplies", prefixReplies.name())
.putBoolean("collapseLongPosts", collapseLongPosts)
.putBoolean("spectatorMode", spectatorMode)
.putBoolean("autoHideFab", autoHideFab)
.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)
.putBoolean("showSuicideHelp", showSuicideHelp)
.putBoolean("underlinedLinks", underlinedLinks)
.putString("color", color.name())
.putBoolean("likeIcon", likeIcon)
// MOSHIDON
.putBoolean("defaultToUnlistedReplies", defaultToUnlistedReplies)
.putBoolean("doubleTapToSearch", doubleTapToSearch)
.putBoolean("doubleTapToSwipe", doubleTapToSwipe)
.putBoolean("replyLineAboveHeader", replyLineAboveHeader)
.putBoolean("confirmBeforeReblog", confirmBeforeReblog)
.putBoolean("swapBookmarkWithBoostAction", swapBookmarkWithBoostAction)
.putBoolean("loadRemoteAccountFollowers", loadRemoteAccountFollowers)
.putBoolean("hapticFeedback", hapticFeedback)
.putBoolean("mentionRebloggerAutomatically", mentionRebloggerAutomatically)
.putBoolean("showDividers", showDividers)
.putBoolean("relocatePublishButton", relocatePublishButton)
.putBoolean("enableDeleteNotifications", enableDeleteNotifications)
.putBoolean("showPostsWithoutAlt", showPostsWithoutAlt)
.putBoolean("showMediaPreview", showMediaPreview)
.apply();
}
public static boolean isOptedOutOfPreReplySheet(PreReplySheetType type, Account account, String accountID){
if(getPreReplyPrefs().getBoolean("opt_out_"+type, false))
return true;
if(account==null)
return false;
String accountKey=account.acct;
if(!accountKey.contains("@"))
accountKey+="@"+AccountSessionManager.get(accountID).domain;
return getPreReplyPrefs().getBoolean("opt_out_"+type+"_"+accountKey.toLowerCase(), false);
}
public static void optOutOfPreReplySheet(PreReplySheetType type, Account account, String accountID){
String key;
if(account==null){
key="opt_out_"+type;
}else{
String accountKey=account.acct;
if(!accountKey.contains("@"))
accountKey+="@"+AccountSessionManager.get(accountID).domain;
key="opt_out_"+type+"_"+accountKey.toLowerCase();
}
getPreReplyPrefs().edit().putBoolean(key, true).apply();
}
public static void resetPreReplySheets(){
getPreReplyPrefs().edit().clear().apply();
}
public enum ThemePreference{
AUTO,
LIGHT,
DARK
}
public enum PreReplySheetType{
OLD_POST,
NON_MUTUAL
public enum AutoRevealMode {
NEVER,
THREADS,
DISCUSSIONS
}
public enum PrefixRepliesMode {
NEVER,
ALWAYS,
TO_OTHERS
}
//region preferences migrations
private static void migrateToUpstreamVersion61(){
Log.d(TAG, "Migrating preferences to upstream version 61!!");
Type accountsDefaultContentTypesType = new TypeToken<Map<String, ContentType>>() {}.getType();
Type pinnedTimelinesType = new TypeToken<Map<String, ArrayList<TimelineDefinition>>>() {}.getType();
Type recentLanguagesType = new TypeToken<Map<String, ArrayList<String>>>() {}.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<String> accountsWithContentTypesEnabled=prefs.getStringSet("accountsWithContentTypesEnabled", new HashSet<>());
Map<String, ContentType> accountsDefaultContentTypes=fromJson(prefs.getString("accountsDefaultContentTypes", null), accountsDefaultContentTypesType, new HashMap<>());
Map<String, ArrayList<TimelineDefinition>> pinnedTimelines=fromJson(prefs.getString("pinnedTimelines", null), pinnedTimelinesType, new HashMap<>());
Set<String> accountsWithLocalOnlySupport=prefs.getStringSet("accountsWithLocalOnlySupport", new HashSet<>());
Set<String> accountsInGlitchMode=prefs.getStringSet("accountsInGlitchMode", new HashSet<>());
Map<String, ArrayList<String>> 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();
}
}
//endregion
}

View File

@@ -1,59 +1,88 @@
package org.joinmastodon.android;
import static org.joinmastodon.android.fragments.ComposeFragment.CAMERA_PERMISSION_CODE;
import static org.joinmastodon.android.fragments.ComposeFragment.CAMERA_PIC_REQUEST_CODE;
import android.Manifest;
import android.app.Application;
import android.app.Activity;
import android.app.Fragment;
import android.app.assist.AssistContent;
import android.content.Intent;
import android.content.pm.PackageManager;
import android.graphics.Bitmap;
import android.net.Uri;
import android.net.Uri;
import android.os.BadParcelableException;
import android.os.Build;
import android.os.Bundle;
import android.provider.MediaStore;
import android.util.Log;
import android.view.View;
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;
import org.joinmastodon.android.fragments.ComposeFragment;
import org.joinmastodon.android.fragments.HomeFragment;
import org.joinmastodon.android.fragments.ProfileFragment;
import org.joinmastodon.android.fragments.SplashFragment;
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;
import org.parceler.Parcels;
import java.lang.reflect.InvocationTargetException;
import androidx.annotation.Nullable;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.PrintWriter;
import java.time.Instant;
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{
public class MainActivity extends FragmentStackActivity implements ProvidesAssistContent {
private static final String TAG="MainActivity";
@Override
protected void onCreate(@Nullable Bundle savedInstanceState){
UiUtils.setUserPreferredTheme(this);
AccountSession session=getCurrentSession();
UiUtils.setUserPreferredTheme(this, session);
super.onCreate(savedInstanceState);
Thread.UncaughtExceptionHandler defaultHandler=Thread.getDefaultUncaughtExceptionHandler();
Thread.setDefaultUncaughtExceptionHandler((t, e)->{
File file=new File(MastodonApp.context.getFilesDir(), "crash.log");
try(FileOutputStream out=new FileOutputStream(file)){
PrintWriter writer=new PrintWriter(out);
writer.println(BuildConfig.VERSION_NAME+" ("+BuildConfig.VERSION_CODE+")");
writer.println(Instant.now().toString());
writer.println();
e.printStackTrace(writer);
writer.flush();
}catch(IOException x){
Log.e(TAG, "Error writing crash.log", x);
}finally{
defaultHandler.uncaughtException(t, e);
}
});
if(savedInstanceState==null){
restartHomeFragment();
}
if(BuildConfig.BUILD_TYPE.startsWith("appcenter")){
// Call the appcenter SDK wrapper through reflection because it is only present in beta builds
try{
Class.forName("org.joinmastodon.android.AppCenterWrapper").getMethod("init", Application.class).invoke(null, getApplication());
}catch(ClassNotFoundException|NoSuchMethodException|IllegalAccessException|InvocationTargetException ignore){}
}else if(GithubSelfUpdater.needSelfUpdating()){
if(GithubSelfUpdater.needSelfUpdating()){
GithubSelfUpdater.getInstance().maybeCheckForUpdates();
}
}
@@ -61,11 +90,12 @@ public class MainActivity extends FragmentStackActivity{
@Override
protected void onNewIntent(Intent intent){
super.onNewIntent(intent);
if(intent.getBooleanExtra("fromNotification", false)){
AccountSessionManager.getInstance().maybeUpdateLocalInfo();
if (intent.hasExtra("fromExternalShare")) showFragmentForExternalShare(intent.getExtras());
else if (intent.getBooleanExtra("fromNotification", false)) {
String accountID=intent.getStringExtra("accountID");
AccountSession accountSession;
try{
accountSession=AccountSessionManager.getInstance().getAccount(accountID);
AccountSessionManager.getInstance().getAccount(accountID);
}catch(IllegalStateException x){
return;
}
@@ -133,23 +163,26 @@ public class MainActivity extends FragmentStackActivity{
}
private void showFragmentForNotification(Notification notification, String accountID){
Fragment fragment;
Bundle args=new Bundle();
args.putString("account", accountID);
args.putBoolean("_can_go_back", true);
try{
notification.postprocess();
}catch(ObjectValidationException x){
Log.w("MainActivity", x);
return;
}
if(notification.status!=null){
fragment=new ThreadFragment();
args.putParcelable("status", Parcels.wrap(notification.status));
}else{
fragment=new ProfileFragment();
args.putParcelable("profileAccount", Parcels.wrap(notification.account));
}
Bundle args = new Bundle();
args.putBoolean("noTransition", true);
UiUtils.showFragmentForNotification(this, notification, accountID, args);
}
private void showFragmentForExternalShare(Bundle args) {
String className = args.getString("fromExternalShare");
Fragment fragment = switch (className) {
case "ThreadFragment" -> new ThreadFragment();
case "ProfileFragment" -> new ProfileFragment();
default -> null;
};
if (fragment == null) return;
args.putBoolean("_can_go_back", true);
fragment.setArguments(args);
showFragment(fragment);
}
@@ -171,31 +204,137 @@ public class MainActivity extends FragmentStackActivity{
}
}
/**
* when opening app through a notification: if (thread) fragment "can go back", clear back stack
* and show home fragment. upstream's implementation doesn't require this as it opens home first
* and then immediately switches to the notification's ThreadFragment. this causes a black
* screen in megalodon, for some reason, so i'm working around this that way.
*/
@Override
public void onBackPressed() {
Fragment currentFragment = getFragmentManager().findFragmentById(
(fragmentContainers.get(fragmentContainers.size() - 1)).getId()
);
Bundle currentArgs = currentFragment.getArguments();
if (fragmentContainers.size() != 1
|| currentArgs == null
|| !currentArgs.getBoolean("_can_go_back", false)) {
super.onBackPressed();
return;
}
if (currentArgs.getBoolean("_finish_on_back", false)) {
finish();
} else if (currentArgs.containsKey("account")) {
Bundle args = new Bundle();
args.putString("account", currentArgs.getString("account"));
if (getIntent().getBooleanExtra("fromNotification", false)) {
args.putString("tab", "notifications");
}
Fragment fragment=new HomeFragment();
fragment.setArguments(args);
showFragmentClearingBackStack(fragment);
}
}
// @Override
// public void onActivityResult(int requestCode, int resultCode, Intent data){
// if(requestCode==CAMERA_PIC_REQUEST_CODE && resultCode== Activity.RESULT_OK){
// E.post(new TakePictureRequestEvent());
// }
// }
@Override
public void onRequestPermissionsResult(int requestCode, String[] permissions, int[] grantResults) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults);
if (requestCode == CAMERA_PERMISSION_CODE && (grantResults.length > 0 && grantResults[0] == PackageManager.PERMISSION_GRANTED)) {
E.post(new TakePictureRequestEvent());
} else {
Toast.makeText(this, R.string.permission_required, Toast.LENGTH_SHORT);
}
}
public Fragment getCurrentFragment() {
for (int i = fragmentContainers.size() - 1; i >= 0; i--) {
FrameLayout fl = fragmentContainers.get(i);
if (fl.getVisibility() == View.VISIBLE) {
return getFragmentManager().findFragmentById(fl.getId());
}
}
return null;
}
@Override
public void onProvideAssistContent(AssistContent assistContent) {
super.onProvideAssistContent(assistContent);
Fragment fragment = getCurrentFragment();
if (fragment != null) callFragmentToProvideAssistContent(fragment, assistContent);
}
public AccountSession getCurrentSession(){
AccountSession session;
Bundle args=new Bundle();
Intent intent=getIntent();
if(intent.hasExtra("fromExternalShare")) {
return AccountSessionManager.getInstance()
.getAccount(intent.getStringExtra("account"));
}
boolean fromNotification = intent.getBooleanExtra("fromNotification", false);
boolean hasNotification = intent.hasExtra("notification");
if(fromNotification){
String accountID=intent.getStringExtra("accountID");
try{
session=AccountSessionManager.getInstance().getAccount(accountID);
if(!hasNotification) args.putString("tab", "notifications");
}catch(IllegalStateException x){
session=AccountSessionManager.getInstance().getLastActiveAccount();
}
}else{
session=AccountSessionManager.getInstance().getLastActiveAccount();
}
return session;
}
public void restartActivity(){
finish();
startActivity(new Intent(this, MainActivity.class));
}
public void restartHomeFragment(){
if(AccountSessionManager.getInstance().getLoggedInAccounts().isEmpty()){
showFragmentClearingBackStack(new SplashFragment());
showFragmentClearingBackStack(new CustomWelcomeFragment());
}else{
AccountSessionManager.getInstance().maybeUpdateLocalInfo();
AccountSession session;
Bundle args=new Bundle();
Intent intent=getIntent();
if(intent.getBooleanExtra("fromNotification", false)){
if(intent.hasExtra("fromExternalShare")) {
AccountSessionManager.getInstance()
.setLastActiveAccountID(intent.getStringExtra("account"));
AccountSessionManager.getInstance().maybeUpdateLocalInfo(
AccountSessionManager.getInstance().getLastActiveAccount());
showFragmentForExternalShare(intent.getExtras());
return;
}
boolean fromNotification = intent.getBooleanExtra("fromNotification", false);
boolean hasNotification = intent.hasExtra("notification");
if(fromNotification){
String accountID=intent.getStringExtra("accountID");
try{
session=AccountSessionManager.getInstance().getAccount(accountID);
if(!intent.hasExtra("notification"))
args.putString("tab", "notifications");
if(!hasNotification) args.putString("tab", "notifications");
}catch(IllegalStateException x){
session=AccountSessionManager.getInstance().getLastActiveAccount();
}
}else{
session=AccountSessionManager.getInstance().getLastActiveAccount();
}
AccountSessionManager.getInstance().maybeUpdateLocalInfo(session);
args.putString("account", session.getID());
Fragment fragment=session.activated ? new HomeFragment() : new AccountActivationFragment();
fragment.setArguments(args);
showFragmentClearingBackStack(fragment);
if(intent.getBooleanExtra("fromNotification", false) && intent.hasExtra("notification")){
if(fromNotification && hasNotification){
// Parcelables might not be compatible across app versions so this protects against possible crashes
// when a notification was received, then the app was updated, and then the user opened the notification
try{
@@ -204,11 +343,12 @@ public class MainActivity extends FragmentStackActivity{
}catch(BadParcelableException x){
Log.w(TAG, x);
}
}else if(intent.getBooleanExtra("compose", false)){
} else if (intent.getBooleanExtra("compose", false)){
showCompose();
}else if(Intent.ACTION_VIEW.equals(intent.getAction())){
} else if (Intent.ACTION_VIEW.equals(intent.getAction())){
handleURL(intent.getData(), null);
}else{
} else {
showFragmentClearingBackStack(fragment);
maybeRequestNotificationsPermission();
}
}

View File

@@ -61,6 +61,9 @@ public class OAuthActivity extends Activity{
@Override
public void onSuccess(Token token){
new GetOwnAccount()
// in case the instance (looking at pixelfed) wants to redirect to a
// website, we need to pass a context so we can launch a browser
.setContext(OAuthActivity.this)
.setCallback(new Callback<>(){
@Override
public void onSuccess(Account account){

View File

@@ -0,0 +1,49 @@
package org.joinmastodon.android;
import android.app.Activity;
import android.content.Intent;
import android.os.Bundle;
import org.joinmastodon.android.api.requests.oauth.RevokeOauthToken;
import org.joinmastodon.android.api.session.AccountSession;
import org.joinmastodon.android.api.session.AccountSessionManager;
import me.grishka.appkit.api.Callback;
import me.grishka.appkit.api.ErrorResponse;
public class PanicResponderActivity extends Activity {
public static final String PANIC_TRIGGER_ACTION = "info.guardianproject.panic.action.TRIGGER";
@Override
protected void onCreate(final Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
final Intent intent = getIntent();
if (intent != null && PANIC_TRIGGER_ACTION.equals(intent.getAction())) {
AccountSessionManager.getInstance().getLoggedInAccounts().forEach(accountSession -> logOut(accountSession.getID()));
ExitActivity.exit(this);
}
finishAndRemoveTask();
}
private void logOut(String accountID){
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(accountID);
}
@Override
public void onError(ErrorResponse error){
onLoggedOut(accountID);
}
})
.exec(accountID);
}
private void onLoggedOut(String accountID){
AccountSessionManager.getInstance().removeAccount(accountID);
}
}

View File

@@ -1,32 +1,52 @@
package org.joinmastodon.android;
import static org.joinmastodon.android.GlobalUserPreferences.PrefixRepliesMode.ALWAYS;
import static org.joinmastodon.android.GlobalUserPreferences.PrefixRepliesMode.TO_OTHERS;
import static org.joinmastodon.android.GlobalUserPreferences.getPrefs;
import android.app.Notification;
import android.app.NotificationChannel;
import android.app.NotificationChannelGroup;
import android.app.NotificationManager;
import android.app.PendingIntent;
import android.app.RemoteInput;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
import android.graphics.Bitmap;
import android.graphics.drawable.Drawable;
import android.graphics.drawable.Icon;
import android.opengl.Visibility;
import android.os.Build;
import android.os.Bundle;
import android.text.TextUtils;
import android.util.Log;
import org.joinmastodon.android.api.MastodonAPIController;
import org.joinmastodon.android.api.requests.accounts.SetAccountFollowed;
import org.joinmastodon.android.api.requests.notifications.GetNotificationByID;
import org.joinmastodon.android.api.requests.statuses.CreateStatus;
import org.joinmastodon.android.api.requests.statuses.SetStatusBookmarked;
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.model.Account;
import org.joinmastodon.android.model.Mention;
import org.joinmastodon.android.model.NotificationAction;
import org.joinmastodon.android.model.Preferences;
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.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;
import me.grishka.appkit.api.Callback;
@@ -39,9 +59,15 @@ public class PushNotificationReceiver extends BroadcastReceiver{
private static final String TAG="PushNotificationReceive";
public static final int NOTIFICATION_ID=178;
private static final String ACTION_KEY_TEXT_REPLY = "ACTION_KEY_TEXT_REPLY";
private static final int SUMMARY_ID = 791;
private static int notificationId = 0;
private static final Map<String, Integer> notificationIdsForAccounts = new HashMap<>();
@Override
public void onReceive(Context context, Intent intent){
UiUtils.setUserPreferredTheme(context);
if(BuildConfig.DEBUG){
Log.e(TAG, "received: "+intent);
Bundle extras=intent.getExtras();
@@ -96,11 +122,47 @@ public class PushNotificationReceiver extends BroadcastReceiver{
Log.w(TAG, "onReceive: invalid push notification format");
}
}
if(intent.getBooleanExtra("fromNotificationAction", false)){
String accountID=intent.getStringExtra("accountID");
int notificationId=intent.getIntExtra("notificationId", -1);
if (notificationId >= 0){
NotificationManager notificationManager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE);
notificationManager.cancel(accountID, notificationId);
}
if(intent.hasExtra("notification")){
org.joinmastodon.android.model.Notification notification=Parcels.unwrap(intent.getParcelableExtra("notification"));
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 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");
}
}
}else{
Log.e(TAG, "onReceive: Failed to load notification");
}
}
}
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);
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){
@@ -144,19 +206,178 @@ public class PushNotificationReceiver extends BroadcastReceiver{
.setContentText(pn.body)
.setStyle(new Notification.BigTextStyle().bigText(pn.body))
.setSmallIcon(R.drawable.ic_ntf_logo)
.setContentIntent(PendingIntent.getActivity(context, accountID.hashCode() & 0xFFFF, contentIntent, PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_UPDATE_CURRENT))
.setContentIntent(PendingIntent.getActivity(context, notificationId, contentIntent, PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_UPDATE_CURRENT))
.setWhen(notification==null ? System.currentTimeMillis() : notification.createdAt.toEpochMilli())
.setShowWhen(true)
.setCategory(Notification.CATEGORY_SOCIAL)
.setAutoCancel(true)
.setLights(context.getColor(R.color.primary_700), 500, 1000)
.setColor(context.getColor(R.color.primary_700));
.setColor(context.getColor(R.color.shortcut_icon_background));
if (!GlobalUserPreferences.uniformNotificationIcon) {
builder.setSmallIcon(switch (pn.notificationType) {
case FAVORITE -> GlobalUserPreferences.likeIcon ? R.drawable.ic_fluent_heart_24_filled : R.drawable.ic_fluent_star_24_filled;
case REBLOG -> R.drawable.ic_fluent_arrow_repeat_all_24_filled;
case FOLLOW -> R.drawable.ic_fluent_person_add_24_filled;
case MENTION -> R.drawable.ic_fluent_mention_24_filled;
case POLL -> R.drawable.ic_fluent_poll_24_filled;
case STATUS -> R.drawable.ic_fluent_chat_24_filled;
case UPDATE -> R.drawable.ic_fluent_history_24_filled;
case REPORT -> R.drawable.ic_fluent_warning_24_filled;
case SIGN_UP -> R.drawable.ic_fluent_person_available_24_filled;
});
}
if(avatar!=null){
builder.setLargeIcon(UiUtils.getBitmapFromDrawable(avatar));
}
if(AccountSessionManager.getInstance().getLoggedInAccounts().size()>1){
builder.setSubText(accountName);
}
nm.notify(accountID, NOTIFICATION_ID, builder.build());
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){
case MENTION, STATUS -> {
if(Build.VERSION.SDK_INT>=Build.VERSION_CODES.N){
builder.addAction(buildReplyAction(context, id, accountID, notification));
}
builder.addAction(buildNotificationAction(context, id, accountID, notification, context.getString(R.string.button_favorite), NotificationAction.FAVORITE));
if(GlobalUserPreferences.swapBookmarkWithBoostAction){
if(notification.status.visibility != StatusPrivacy.DIRECT) {
builder.addAction(buildNotificationAction(context, id, accountID, notification, context.getString(R.string.button_reblog), NotificationAction.BOOST));
}else{
// This is just so there is a bookmark action if you cannot reblog the toot
builder.addAction(buildNotificationAction(context, id, accountID, notification, context.getString(R.string.add_bookmark), NotificationAction.BOOKMARK));
}
} else {
builder.addAction(buildNotificationAction(context, id, accountID, notification, context.getString(R.string.add_bookmark), NotificationAction.BOOKMARK));
}
}
case UPDATE -> {
if(notification.status.reblogged)
builder.addAction(buildNotificationAction(context, id, accountID, notification, context.getString(R.string.sk_undo_reblog), NotificationAction.UNBOOST));
}
case FOLLOW -> {
builder.addAction(buildNotificationAction(context, id, accountID, notification, context.getString(R.string.follow_back), NotificationAction.FOLLOW_BACK));
}
}
}
nm.notify(accountID, id, builder.build());
}
private Notification.Action buildNotificationAction(Context context, int notificationId, String accountID, org.joinmastodon.android.model.Notification notification, String title, NotificationAction action){
Intent notificationIntent=new Intent(context, PushNotificationReceiver.class);
notificationIntent.putExtra("notificationId", notificationId);
notificationIntent.putExtra("fromNotificationAction", true);
notificationIntent.putExtra("accountID", accountID);
notificationIntent.putExtra("notificationAction", action.ordinal());
notificationIntent.putExtra("notification", Parcels.wrap(notification));
PendingIntent actionPendingIntent = PendingIntent.getBroadcast(context, new Random().nextInt(), notificationIntent, PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_ONE_SHOT);
return new Notification.Action.Builder(null, title, actionPendingIntent).build();
}
private Notification.Action buildReplyAction(Context context, int notificationId, String accountID, org.joinmastodon.android.model.Notification notification){
String replyLabel = context.getResources().getString(R.string.button_reply);
RemoteInput remoteInput = new RemoteInput.Builder(ACTION_KEY_TEXT_REPLY)
.setLabel(replyLabel)
.build();
Intent notificationIntent=new Intent(context, PushNotificationReceiver.class);
notificationIntent.putExtra("notificationId", notificationId);
notificationIntent.putExtra("fromNotificationAction", true);
notificationIntent.putExtra("accountID", accountID);
notificationIntent.putExtra("notificationAction", NotificationAction.REPLY.ordinal());
notificationIntent.putExtra("notification", Parcels.wrap(notification));
int flags = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S ? PendingIntent.FLAG_MUTABLE | PendingIntent.FLAG_UPDATE_CURRENT : PendingIntent.FLAG_UPDATE_CURRENT;
PendingIntent replyPendingIntent = PendingIntent.getBroadcast(context, new Random().nextInt(), notificationIntent,flags);
return new Notification.Action.Builder(null, replyLabel, replyPendingIntent).addRemoteInput(remoteInput).build();
}
private void handleReplyAction(Context context, String accountID, Intent intent, org.joinmastodon.android.model.Notification notification, int notificationId, Preferences preferences) {
Bundle remoteInput = RemoteInput.getResultsFromIntent(intent);
if (remoteInput == null) {
Log.e(TAG, "handleReplyAction: Could not get reply input");
return;
}
CharSequence input = remoteInput.getCharSequence(ACTION_KEY_TEXT_REPLY);
// copied from ComposeFragment - TODO: generalize?
ArrayList<String> mentions=new ArrayList<>();
Status status = notification.status;
String ownID=AccountSessionManager.getInstance().getAccount(accountID).self.id;
if(!status.account.id.equals(ownID))
mentions.add('@'+status.account.acct);
for(Mention mention:status.mentions){
if(mention.id.equals(ownID))
continue;
String m='@'+mention.acct;
if(!mentions.contains(m))
mentions.add(m);
}
String initialText=mentions.isEmpty() ? "" : TextUtils.join(" ", mentions)+" ";
CreateStatus.Request req=new CreateStatus.Request();
req.status = initialText + input.toString();
req.language = notification.status.language;
req.visibility = (notification.status.visibility == StatusPrivacy.PUBLIC && GlobalUserPreferences.defaultToUnlistedReplies ? StatusPrivacy.UNLISTED : notification.status.visibility);
req.inReplyToId = notification.status.id;
if (notification.status.hasSpoiler() &&
(GlobalUserPreferences.prefixReplies == ALWAYS
|| (GlobalUserPreferences.prefixReplies == TO_OTHERS && !ownID.equals(notification.status.account.id)))
&& !notification.status.spoilerText.startsWith("re: ")) {
req.spoilerText = "re: " + notification.status.spoilerText;
}
new CreateStatus(req, UUID.randomUUID().toString()).setCallback(new Callback<>() {
@Override
public void onSuccess(Status status) {
NotificationManager notificationManager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE);
Notification.Builder builder = android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.O ?
new Notification.Builder(context, accountID+"_"+notification.type) :
new Notification.Builder(context)
.setPriority(Notification.PRIORITY_DEFAULT)
.setDefaults(Notification.DEFAULT_SOUND | Notification.DEFAULT_VIBRATE);
notification.status = status;
Intent contentIntent=new Intent(context, MainActivity.class);
contentIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
contentIntent.putExtra("fromNotification", true);
contentIntent.putExtra("accountID", accountID);
contentIntent.putExtra("notification", Parcels.wrap(notification));
Notification repliedNotification = builder.setSmallIcon(R.drawable.ic_ntf_logo)
.setContentTitle(context.getString(R.string.sk_notification_action_replied, notification.status.account.displayName))
.setContentText(status.getStrippedText())
.setCategory(Notification.CATEGORY_SOCIAL)
.setContentIntent(PendingIntent.getActivity(context, notificationId, contentIntent, PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_UPDATE_CURRENT))
.build();
notificationManager.notify(accountID, notificationId, repliedNotification);
}
@Override
public void onError(ErrorResponse errorResponse) {
}
}).exec(accountID);
}
}

View File

@@ -0,0 +1,84 @@
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().tryGetAccount(instance);
if (account != null)
account.getPushSubscriptionManager().registerAccountForPush(null, endpoint);
}
@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().tryGetAccount(instance);
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().tryGetAccount(instance);
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().tryGetAccount(instance);
if (account == null)
return;
//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<List<Notification>> result){
result.items
.stream()
.findFirst()
.ifPresent(value->MastodonAPIController.runInBackground(()->new PushNotificationReceiver().notifyUnifiedPush(context, instance, value)));
}
@Override
public void onError(ErrorResponse error){
//professional error handling
}
});
}
}

View File

@@ -11,7 +11,7 @@ public class ApiUtils{
//no instance
}
public static <E extends Enum<E>> List<String> enumSetToStrings(EnumSet<E> e, Class<E> cls){
public static <E extends Enum<E>> List<String> enumSetToStrings(EnumSet<E> e, Class<E> cls){
return e.stream().map(ev->{
try{
SerializedName annotation=cls.getField(ev.name()).getAnnotation(SerializedName.class);

View File

@@ -20,6 +20,7 @@ import org.joinmastodon.android.api.session.AccountSessionManager;
import org.joinmastodon.android.model.CacheablePaginatedResponse;
import org.joinmastodon.android.model.FilterContext;
import org.joinmastodon.android.model.FollowList;
import org.joinmastodon.android.model.Instance;
import org.joinmastodon.android.model.Notification;
import org.joinmastodon.android.model.PaginatedResponse;
import org.joinmastodon.android.model.SearchResult;
@@ -36,6 +37,7 @@ import java.util.ArrayList;
import java.util.Comparator;
import java.util.EnumSet;
import java.util.List;
import java.util.Objects;
import java.util.function.Consumer;
import me.grishka.appkit.api.Callback;
@@ -44,7 +46,7 @@ import me.grishka.appkit.utils.WorkerThread;
public class CacheController{
private static final String TAG="CacheController";
private static final int DB_VERSION=3;
private static final int DB_VERSION=4;
private static final WorkerThread databaseThread=new WorkerThread("databaseThread");
private static final Handler uiHandler=new Handler(Looper.getMainLooper());
@@ -80,12 +82,11 @@ public class CacheController{
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);
status.hasGapAfter=((flags & POST_FLAG_GAP_AFTER)!=0) ? status.id : null;
newMaxID=status.id;
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;
}
@@ -93,13 +94,11 @@ 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<Status> result){
ArrayList<Status> 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));
callback.onSuccess(new CacheablePaginatedResponse<>(result, result.isEmpty() ? null : result.get(result.size()-1).id, false));
putHomeTimeline(result, maxID==null);
}
@@ -127,20 +126,45 @@ public class CacheController{
values.put("id", s.id);
values.put("json", MastodonAPIController.gson.toJson(s));
int flags=0;
if(s.hasGapAfter)
if(Objects.equals(s.hasGapAfter, s.id))
flags|=POST_FLAG_GAP_AFTER;
values.put("flags", flags);
values.put("time", s.createdAt.getEpochSecond());
db.insertWithOnConflict("home_timeline", null, values, SQLiteDatabase.CONFLICT_REPLACE);
}
if(!clear)
db.delete("home_timeline", "`id` NOT IN (SELECT `id` FROM `home_timeline` ORDER BY `time` DESC LIMIT ?)", new String[]{"1000"});
});
}
public void getNotifications(String maxID, int count, boolean onlyMentions, boolean forceReload, Callback<PaginatedResponse<List<Notification>>> 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<PaginatedResponse<List<Notification>>> callback){
cancelDelayedClose();
databaseThread.postRunnable(()->{
try{
if(!onlyMentions && loadingNotifications){
if(!onlyMentions && !onlyPosts && loadingNotifications){
synchronized(pendingNotificationsCallbacks){
pendingNotificationsCallbacks.add(callback);
}
@@ -148,7 +172,8 @@ public class CacheController{
}
if(!forceReload){
SQLiteDatabase db=getOrOpenDatabase();
try(Cursor cursor=db.query(onlyMentions ? "notifications_mentions" : "notifications_all", new String[]{"json"}, maxID==null ? null : "`id`<?", maxID==null ? null : new String[]{maxID}, null, null, "`time` DESC", count+"")){
String table=onlyPosts ? "notifications_posts" : onlyMentions ? "notifications_mentions" : "notifications_all";
try(Cursor cursor=db.query(table, new String[]{"json"}, maxID==null ? null : "`id`<?", maxID==null ? null : new String[]{maxID}, null, null, "`time` DESC", count+"")){
if(cursor.getCount()==count){
ArrayList<Notification> result=new ArrayList<>();
cursor.moveToFirst();
@@ -168,9 +193,10 @@ public class CacheController{
Log.w(TAG, "getNotifications: corrupted notification object in database", x);
}
}
if(!onlyMentions)
if(!onlyMentions && !onlyPosts)
loadingNotifications=true;
new GetNotifications(maxID, count, onlyMentions ? EnumSet.of(Notification.Type.MENTION): EnumSet.allOf(Notification.Type.class))
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<Notification> result){
@@ -178,7 +204,7 @@ public class CacheController{
AccountSessionManager.get(accountID).filterStatusContainingObjects(filtered, n->n.status, FilterContext.NOTIFICATIONS);
PaginatedResponse<List<Notification>> res=new PaginatedResponse<>(filtered, result.isEmpty() ? null : result.get(result.size()-1).id);
callback.onSuccess(res);
putNotifications(result, onlyMentions, maxID==null);
putNotifications(result, onlyMentions, onlyPosts, maxID==null);
if(!onlyMentions){
loadingNotifications=false;
synchronized(pendingNotificationsCallbacks){
@@ -214,9 +240,9 @@ public class CacheController{
}, 0);
}
private void putNotifications(List<Notification> notifications, boolean onlyMentions, boolean clear){
private void putNotifications(List<Notification> notifications, boolean onlyMentions, boolean onlyPosts, boolean clear){
runOnDbThread((db)->{
String table=onlyMentions ? "notifications_mentions" : "notifications_all";
String table=onlyPosts ? "notifications_posts" : onlyMentions ? "notifications_mentions" : "notifications_all";
if(clear)
db.delete(table, null, null);
ContentValues values=new ContentValues(4);
@@ -259,6 +285,28 @@ public class CacheController{
public void deleteStatus(String id){
runOnDbThread((db)->{
String gapId=null;
int gapFlags=0;
// select to-be-removed and newer row
try(Cursor cursor=db.query("home_timeline", new String[]{"id", "flags"}, "`time`>=(SELECT `time` FROM `home_timeline` WHERE `id`=?)", new String[]{id}, null, null, "`time` ASC", "2")){
boolean hadGapAfter=false;
// always either one or two iterations (only one if there's no newer post)
while(cursor.moveToNext()){
String currentId=cursor.getString(0);
int currentFlags=cursor.getInt(1);
if(currentId.equals(id)){
hadGapAfter=((currentFlags & POST_FLAG_GAP_AFTER)!=0);
}else if(hadGapAfter){
gapFlags=currentFlags|POST_FLAG_GAP_AFTER;
gapId=currentId;
}
}
}
if(gapId!=null){
ContentValues values=new ContentValues();
values.put("flags", gapFlags);
db.update("home_timeline", values, "`id`=?", new String[]{gapId});
}
db.delete("home_timeline", "`id`=?", new String[]{id});
});
}
@@ -437,6 +485,7 @@ public class CacheController{
`time` INTEGER NOT NULL
)""");
createRecentSearchesTable(db);
createPostsNotificationsTable(db);
}
@Override
@@ -445,6 +494,10 @@ public class CacheController{
createRecentSearchesTable(db);
}
if(oldVersion<3){
// MEGALODON
createPostsNotificationsTable(db);
}
if(oldVersion<4){
addTimeColumns(db);
}
}
@@ -458,13 +511,26 @@ public class CacheController{
)""");
}
private void createPostsNotificationsTable(SQLiteDatabase db){
db.execSQL("""
CREATE TABLE `notifications_posts` (
`id` VARCHAR(25) NOT NULL PRIMARY KEY,
`json` TEXT NOT NULL,
`flags` INTEGER NOT NULL DEFAULT 0,
`type` INTEGER NOT NULL,
`time` INTEGER NOT NULL
)""");
}
private void addTimeColumns(SQLiteDatabase db){
db.execSQL("DELETE FROM `home_timeline`");
db.execSQL("DELETE FROM `notifications_all`");
db.execSQL("DELETE FROM `notifications_mentions`");
db.execSQL("DELETE FROM `notifications_posts`");
db.execSQL("ALTER TABLE `home_timeline` ADD `time` INTEGER NOT NULL DEFAULT 0");
db.execSQL("ALTER TABLE `notifications_all` ADD `time` INTEGER NOT NULL DEFAULT 0");
db.execSQL("ALTER TABLE `notifications_mentions` ADD `time` INTEGER NOT NULL DEFAULT 0");
db.execSQL("ALTER TABLE `notifications_posts` ADD `time` INTEGER NOT NULL DEFAULT 0");
}
}

View File

@@ -12,11 +12,16 @@ import com.google.gson.JsonParser;
import com.google.gson.JsonSyntaxException;
import org.joinmastodon.android.BuildConfig;
import org.joinmastodon.android.MastodonApp;
import org.joinmastodon.android.api.gson.IsoInstantTypeAdapter;
import org.joinmastodon.android.api.gson.IsoLocalDateTypeAdapter;
import org.joinmastodon.android.api.session.AccountSession;
import org.joinmastodon.android.model.Status;
import org.joinmastodon.android.ui.utils.UiUtils;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.Reader;
import java.time.Instant;
import java.time.LocalDate;
@@ -38,12 +43,15 @@ import okhttp3.ResponseBody;
public class MastodonAPIController{
private static final String TAG="MastodonAPIController";
public static final Gson gson=new GsonBuilder()
public static final Gson gsonWithoutDeserializer = new GsonBuilder()
.disableHtmlEscaping()
.setFieldNamingPolicy(FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES)
.registerTypeAdapter(Instant.class, new IsoInstantTypeAdapter())
.registerTypeAdapter(LocalDate.class, new IsoLocalDateTypeAdapter())
.create();
public static final Gson gson = gsonWithoutDeserializer.newBuilder()
.registerTypeAdapter(Status.class, new Status.StatusDeserializer())
.create();
private static WorkerThread thread=new WorkerThread("MastodonAPIController");
private static OkHttpClient httpClient=new OkHttpClient.Builder()
.connectTimeout(60, TimeUnit.SECONDS)
@@ -52,9 +60,26 @@ public class MastodonAPIController{
.build();
private AccountSession session;
private static List<String> badDomains = new ArrayList<>();
static{
thread.start();
try {
final BufferedReader reader = new BufferedReader(new InputStreamReader(
MastodonApp.context.getAssets().open("blocks.txt")
));
String line;
while ((line = reader.readLine()) != null) {
if (line.isBlank() || line.startsWith("#")) continue;
String[] parts = line.replaceAll("\"", "").split("[\s,;]");
if (parts.length == 0) continue;
String domain = parts[0].toLowerCase().trim();
if (domain.isBlank()) continue;
badDomains.add(domain);
}
} catch (IOException e) {
e.printStackTrace();
}
}
public MastodonAPIController(@Nullable AccountSession session){
@@ -62,14 +87,17 @@ public class MastodonAPIController{
}
public <T> void submitRequest(final MastodonAPIRequest<T> req){
final String host = req.getURL().getHost();
final boolean isBad = host == null || badDomains.stream().anyMatch(h -> h.equalsIgnoreCase(host) || host.toLowerCase().endsWith("." + h));
thread.postRunnable(()->{
try{
// if (isBad) throw new IllegalArgumentException();
if(req.canceled)
return;
Request.Builder builder=new Request.Builder()
.url(req.getURL().toString())
.method(req.getMethod(), req.getRequestBody())
.header("User-Agent", "MastodonAndroid/"+BuildConfig.VERSION_NAME);
.header("User-Agent", "MoshidonAndroid/"+BuildConfig.VERSION_NAME);
String token=null;
if(session!=null)
@@ -87,13 +115,13 @@ public class MastodonAPIController{
}
Request hreq=builder.build();
Call call=httpClient.newCall(hreq);
OkHttpClient client=req.timeout>0
? httpClient.newBuilder().readTimeout(req.timeout, TimeUnit.MILLISECONDS).build()
: httpClient;
Call call=client.newCall(hreq);
synchronized(req){
req.okhttpCall=call;
}
if(req.timeout>0){
call.timeout().timeout(req.timeout, TimeUnit.MILLISECONDS);
}
if(BuildConfig.DEBUG)
Log.d(TAG, logTag(session)+"Sending request: "+hreq);
@@ -143,6 +171,11 @@ public class MastodonAPIController{
respObj=null;
}
}catch(JsonIOException|JsonSyntaxException x){
if (req.context != null && response.body().contentType().subtype().equals("html")) {
UiUtils.launchWebBrowser(req.context, response.request().url().toString());
req.cancel();
return;
}
if(BuildConfig.DEBUG)
Log.w(TAG, logTag(session)+response+" error parsing or reading body", x);
req.onError(x.getLocalizedMessage(), response.code(), x);

View File

@@ -2,6 +2,7 @@ package org.joinmastodon.android.api;
import android.app.Activity;
import android.app.ProgressDialog;
import android.content.Context;
import android.net.Uri;
import android.util.Log;
import android.util.Pair;
@@ -20,8 +21,11 @@ import java.util.HashMap;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.function.Consumer;
import androidx.annotation.CallSuper;
import androidx.annotation.Nullable;
import androidx.annotation.StringRes;
import me.grishka.appkit.api.APIRequest;
import me.grishka.appkit.api.Callback;
@@ -43,11 +47,12 @@ public abstract class MastodonAPIRequest<T> extends APIRequest<T>{
TypeToken<T> respTypeToken;
Call okhttpCall;
Token token;
boolean canceled;
boolean canceled, isRemote;
Map<String, String> headers;
long timeout;
private ProgressDialog progressDialog;
protected boolean removeUnsupportedItems;
@Nullable Context context;
public MastodonAPIRequest(HttpMethod method, String path, Class<T> respClass){
this.path=path;
@@ -101,10 +106,30 @@ public abstract class MastodonAPIRequest<T> extends APIRequest<T>{
return this;
}
public MastodonAPIRequest<T> wrapProgress(Activity activity, @StringRes int message, boolean cancelable){
progressDialog=new ProgressDialog(activity);
progressDialog.setMessage(activity.getString(message));
public MastodonAPIRequest<T> execRemote(String domain) {
return execRemote(domain, null);
}
public MastodonAPIRequest<T> execRemote(String domain, @Nullable AccountSession remoteSession) {
this.isRemote = true;
return Optional.ofNullable(remoteSession)
.or(() -> AccountSessionManager.getInstance().getLoggedInAccounts().stream()
.filter(acc -> acc.domain.equals(domain))
.findAny())
.map(AccountSession::getID)
.map(this::exec)
.orElseGet(() -> this.execNoAuth(domain));
}
public MastodonAPIRequest<T> wrapProgress(Context context, @StringRes int message, boolean cancelable){
return wrapProgress(context, message, cancelable, null);
}
public MastodonAPIRequest<T> wrapProgress(Context context, @StringRes int message, boolean cancelable, Consumer<ProgressDialog> transform){
progressDialog=new ProgressDialog(context);
progressDialog.setMessage(context.getString(message));
progressDialog.setCancelable(cancelable);
if (transform != null) transform.accept(progressDialog);
if(cancelable){
progressDialog.setOnCancelListener(dialog->cancel());
}
@@ -128,8 +153,9 @@ public abstract class MastodonAPIRequest<T> extends APIRequest<T>{
headers.put(key, value);
}
protected void setTimeout(long timeout){
public MastodonAPIRequest<T> setTimeout(long timeout){
this.timeout=timeout;
return this;
}
protected String getPathPrefix(){
@@ -165,9 +191,20 @@ public abstract class MastodonAPIRequest<T> extends APIRequest<T>{
return this;
}
public MastodonAPIRequest<T> setContext(Context context) {
this.context = context;
return this;
}
@Nullable
public Context getContext() {
return context;
}
@CallSuper
public void validateAndPostprocessResponse(T respObj, Response httpResponse) throws IOException{
if(respObj instanceof BaseModel){
((BaseModel) respObj).isRemote = isRemote;
((BaseModel) respObj).postprocess();
}else if(respObj instanceof List){
if(removeUnsupportedItems){
@@ -176,6 +213,7 @@ public abstract class MastodonAPIRequest<T> extends APIRequest<T>{
Object item=itr.next();
if(item instanceof BaseModel){
try{
((BaseModel) item).isRemote = isRemote;
((BaseModel) item).postprocess();
}catch(ObjectValidationException x){
Log.w(TAG, "Removing invalid object from list", x);
@@ -183,15 +221,20 @@ public abstract class MastodonAPIRequest<T> extends APIRequest<T>{
}
}
}
// no idea why we're post-processing twice, but well, as long
// as upstream does it like this, i don't wanna break anything
for(Object item:((List<?>) respObj)){
if(item instanceof BaseModel){
((BaseModel) item).isRemote = isRemote;
((BaseModel) item).postprocess();
}
}
}else{
for(Object item:((List<?>) respObj)){
if(item instanceof BaseModel)
if(item instanceof BaseModel) {
((BaseModel) item).isRemote = isRemote;
((BaseModel) item).postprocess();
}
}
}
}

View File

@@ -5,8 +5,6 @@ import android.view.View;
import android.widget.TextView;
import android.widget.Toast;
import org.joinmastodon.android.R;
import me.grishka.appkit.api.ErrorResponse;
public class MastodonErrorResponse extends ErrorResponse{
@@ -22,7 +20,7 @@ public class MastodonErrorResponse extends ErrorResponse{
@Override
public void bindErrorView(View view){
TextView text=view.findViewById(R.id.error_text);
TextView text=view.findViewById(me.grishka.appkit.R.id.error_text);
text.setText(error);
}

View File

@@ -120,9 +120,21 @@ public class PushSubscriptionManager{
return !TextUtils.isEmpty(deviceToken);
}
public void registerAccountForPush(PushSubscription subscription){
if(TextUtils.isEmpty(deviceToken))
throw new IllegalStateException("No device push token available");
// 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") || TextUtils.isEmpty(deviceToken)){
Log.d(TAG, "Skipping registering for FCM push notifications");
return;
}
String endpoint = "https://app.joinmastodon.org/relay-to/fcm/"+deviceToken+"/";
registerAccountForPush(subscription, endpoint);
}
public void registerAccountForPush(PushSubscription subscription, String endpoint){
MastodonAPIController.runInBackground(()->{
Log.d(TAG, "registerAccountForPush: started for "+accountID);
String encodedPublicKey, encodedAuthKey, pushAccountID;
@@ -151,12 +163,17 @@ public class PushSubscriptionManager{
Log.e(TAG, "registerAccountForPush: error generating encryption key", e);
return;
}
new RegisterForPushNotifications(deviceToken,
//work-around for adding the randomAccountId
String newEndpoint = endpoint;
if (endpoint.startsWith("https://app.joinmastodon.org/relay-to/fcm/"))
newEndpoint += pushAccountID;
new RegisterForPushNotifications(newEndpoint,
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){
@@ -367,7 +384,7 @@ public class PushSubscriptionManager{
for(AccountSession session:AccountSessionManager.getInstance().getLoggedInAccounts()){
if(session.pushSubscription==null || forceReRegister)
session.getPushSubscriptionManager().registerAccountForPush(session.pushSubscription);
else if(session.needUpdatePushSettings)
else
session.getPushSubscriptionManager().updatePushSettings(session.pushSubscription);
}
}

View File

@@ -6,26 +6,39 @@ import org.joinmastodon.android.E;
import org.joinmastodon.android.MastodonApp;
import org.joinmastodon.android.api.requests.statuses.SetStatusBookmarked;
import org.joinmastodon.android.api.requests.statuses.SetStatusFavorited;
import org.joinmastodon.android.api.requests.statuses.SetStatusMuted;
import org.joinmastodon.android.api.requests.statuses.SetStatusReblogged;
import org.joinmastodon.android.events.ReblogDeletedEvent;
import org.joinmastodon.android.events.StatusCountersUpdatedEvent;
import org.joinmastodon.android.events.StatusCreatedEvent;
import org.joinmastodon.android.events.StatusDeletedEvent;
import org.joinmastodon.android.model.Status;
import org.joinmastodon.android.model.StatusPrivacy;
import java.util.HashMap;
import java.util.function.Consumer;
import me.grishka.appkit.api.Callback;
import me.grishka.appkit.api.ErrorResponse;
public class StatusInteractionController{
private final String accountID;
private final boolean updateCounters;
private final HashMap<String, SetStatusFavorited> runningFavoriteRequests=new HashMap<>();
private final HashMap<String, SetStatusReblogged> runningReblogRequests=new HashMap<>();
private final HashMap<String, SetStatusBookmarked> runningBookmarkRequests=new HashMap<>();
private final HashMap<String, SetStatusMuted> runningMuteRequests=new HashMap<>();
public StatusInteractionController(String accountID){
public StatusInteractionController(String accountID, boolean updateCounters) {
this.accountID=accountID;
this.updateCounters=updateCounters;
}
public void setFavorited(Status status, boolean favorited){
public StatusInteractionController(String accountID){
this(accountID, true);
}
public void setFavorited(Status status, boolean favorited, Consumer<Status> cb){
if(!Looper.getMainLooper().isCurrentThread())
throw new IllegalStateException("Can only be called from main thread");
@@ -38,7 +51,9 @@ public class StatusInteractionController{
@Override
public void onSuccess(Status result){
runningFavoriteRequests.remove(status.id);
E.post(new StatusCountersUpdatedEvent(result));
result.favouritesCount = Math.max(0, status.favouritesCount + (favorited ? 1 : -1));
cb.accept(result);
if(updateCounters) E.post(new StatusCountersUpdatedEvent(result));
}
@Override
@@ -46,24 +61,17 @@ public class StatusInteractionController{
runningFavoriteRequests.remove(status.id);
error.showToast(MastodonApp.context);
status.favourited=!favorited;
if(favorited)
status.favouritesCount--;
else
status.favouritesCount++;
E.post(new StatusCountersUpdatedEvent(status));
cb.accept(status);
if(updateCounters) E.post(new StatusCountersUpdatedEvent(status));
}
})
.exec(accountID);
runningFavoriteRequests.put(status.id, req);
status.favourited=favorited;
if(favorited)
status.favouritesCount++;
else
status.favouritesCount--;
E.post(new StatusCountersUpdatedEvent(status));
if(updateCounters) E.post(new StatusCountersUpdatedEvent(status));
}
public void setReblogged(Status status, boolean reblogged){
public void setReblogged(Status status, boolean reblogged, StatusPrivacy visibility, Consumer<Status> cb){
if(!Looper.getMainLooper().isCurrentThread())
throw new IllegalStateException("Can only be called from main thread");
@@ -71,12 +79,19 @@ public class StatusInteractionController{
if(current!=null){
current.cancel();
}
SetStatusReblogged req=(SetStatusReblogged) new SetStatusReblogged(status.id, reblogged)
SetStatusReblogged req=(SetStatusReblogged) new SetStatusReblogged(status.id, reblogged, visibility)
.setCallback(new Callback<>(){
@Override
public void onSuccess(Status result){
public void onSuccess(Status reblog){
Status result=reblog.getContentStatus();
runningReblogRequests.remove(status.id);
E.post(new StatusCountersUpdatedEvent(result));
result.reblogsCount = Math.max(0, status.reblogsCount + (reblogged ? 1 : -1));
cb.accept(result);
if(updateCounters){
E.post(new StatusCountersUpdatedEvent(result));
if(reblogged) E.post(new StatusCreatedEvent(reblog, accountID));
else E.post(new ReblogDeletedEvent(status.id, accountID));
}
}
@Override
@@ -84,24 +99,21 @@ public class StatusInteractionController{
runningReblogRequests.remove(status.id);
error.showToast(MastodonApp.context);
status.reblogged=!reblogged;
if(reblogged)
status.reblogsCount--;
else
status.reblogsCount++;
E.post(new StatusCountersUpdatedEvent(status));
cb.accept(status);
if(updateCounters) E.post(new StatusCountersUpdatedEvent(status));
}
})
.exec(accountID);
runningReblogRequests.put(status.id, req);
status.reblogged=reblogged;
if(reblogged)
status.reblogsCount++;
else
status.reblogsCount--;
E.post(new StatusCountersUpdatedEvent(status));
if(updateCounters) E.post(new StatusCountersUpdatedEvent(status));
}
public void setBookmarked(Status status, boolean bookmarked){
setBookmarked(status, bookmarked, r->{});
}
public void setBookmarked(Status status, boolean bookmarked, Consumer<Status> cb){
if(!Looper.getMainLooper().isCurrentThread())
throw new IllegalStateException("Can only be called from main thread");
@@ -114,7 +126,8 @@ public class StatusInteractionController{
@Override
public void onSuccess(Status result){
runningBookmarkRequests.remove(status.id);
E.post(new StatusCountersUpdatedEvent(result));
cb.accept(result);
if(updateCounters) E.post(new StatusCountersUpdatedEvent(result));
}
@Override
@@ -122,12 +135,13 @@ public class StatusInteractionController{
runningBookmarkRequests.remove(status.id);
error.showToast(MastodonApp.context);
status.bookmarked=!bookmarked;
E.post(new StatusCountersUpdatedEvent(status));
cb.accept(status);
if(updateCounters) E.post(new StatusCountersUpdatedEvent(status));
}
})
.exec(accountID);
runningBookmarkRequests.put(status.id, req);
status.bookmarked=bookmarked;
E.post(new StatusCountersUpdatedEvent(status));
if(updateCounters) E.post(new StatusCountersUpdatedEvent(status));
}
}

View File

@@ -0,0 +1,11 @@
package org.joinmastodon.android.api.requests.accounts;
import org.joinmastodon.android.api.MastodonAPIRequest;
import org.joinmastodon.android.model.Relationship;
public class AuthorizeFollowRequest extends MastodonAPIRequest<Relationship>{
public AuthorizeFollowRequest(String id){
super(HttpMethod.POST, "/follow_requests/"+id+"/authorize", Relationship.class);
setRequestBody(new Object());
}
}

View File

@@ -0,0 +1,16 @@
package org.joinmastodon.android.api.requests.accounts;
import com.google.gson.reflect.TypeToken;
import org.joinmastodon.android.api.requests.HeaderPaginationRequest;
import org.joinmastodon.android.model.Account;
public class GetAccountBlocks extends HeaderPaginationRequest<Account>{
public GetAccountBlocks(String maxID, int limit){
super(HttpMethod.GET, "/blocks", new TypeToken<>(){});
if(maxID!=null)
addQueryParameter("max_id", maxID);
if(limit>0)
addQueryParameter("limit", limit+"");
}
}

View File

@@ -0,0 +1,15 @@
package org.joinmastodon.android.api.requests.accounts;
import org.joinmastodon.android.api.MastodonAPIRequest;
import org.joinmastodon.android.model.Account;
public class GetAccountByHandle extends MastodonAPIRequest<Account>{
/**
* note that this method usually only returns a result if the instance already knows about an
* account - so it makes sense for looking up local users, search might be preferred otherwise
*/
public GetAccountByHandle(String acct){
super(HttpMethod.GET, "/accounts/lookup", Account.class);
addQueryParameter("acct", acct);
}
}

View File

@@ -0,0 +1,16 @@
package org.joinmastodon.android.api.requests.accounts;
import com.google.gson.reflect.TypeToken;
import org.joinmastodon.android.api.requests.HeaderPaginationRequest;
import org.joinmastodon.android.model.Account;
public class GetAccountMutes extends HeaderPaginationRequest<Account>{
public GetAccountMutes(String maxID, int limit){
super(HttpMethod.GET, "/mutes/", new TypeToken<>(){});
if(maxID!=null)
addQueryParameter("max_id", maxID);
if(limit>0)
addQueryParameter("limit", limit+"");
}
}

View File

@@ -0,0 +1,16 @@
package org.joinmastodon.android.api.requests.accounts;
import com.google.gson.reflect.TypeToken;
import org.joinmastodon.android.api.requests.HeaderPaginationRequest;
import org.joinmastodon.android.model.Account;
public class GetFollowRequests extends HeaderPaginationRequest<Account>{
public GetFollowRequests(String maxID, int limit){
super(HttpMethod.GET, "/follow_requests", new TypeToken<>(){});
if(maxID!=null)
addQueryParameter("max_id", maxID);
if(limit>0)
addQueryParameter("limit", ""+limit);
}
}

View File

@@ -0,0 +1,11 @@
package org.joinmastodon.android.api.requests.accounts;
import org.joinmastodon.android.api.MastodonAPIRequest;
import org.joinmastodon.android.model.Relationship;
public class RejectFollowRequest extends MastodonAPIRequest<Relationship>{
public RejectFollowRequest(String id){
super(HttpMethod.POST, "/follow_requests/"+id+"/reject", Relationship.class);
setRequestBody(new Object());
}
}

View File

@@ -5,9 +5,13 @@ import org.joinmastodon.android.model.Relationship;
public class SetAccountFollowed extends MastodonAPIRequest<Relationship>{
public SetAccountFollowed(String id, boolean followed, boolean showReblogs){
this(id, followed, showReblogs, false);
}
public SetAccountFollowed(String id, boolean followed, boolean showReblogs, boolean notify){
super(HttpMethod.POST, "/accounts/"+id+"/"+(followed ? "follow" : "unfollow"), Relationship.class);
if(followed)
setRequestBody(new Request(showReblogs, null));
setRequestBody(new Request(showReblogs, notify));
else
setRequestBody(new Object());
}

View File

@@ -4,8 +4,15 @@ import org.joinmastodon.android.api.MastodonAPIRequest;
import org.joinmastodon.android.model.Relationship;
public class SetAccountMuted extends MastodonAPIRequest<Relationship>{
public SetAccountMuted(String id, boolean muted){
public SetAccountMuted(String id, boolean muted, long duration){
super(HttpMethod.POST, "/accounts/"+id+"/"+(muted ? "mute" : "unmute"), Relationship.class);
setRequestBody(new Object());
setRequestBody(new Request(duration));
}
private static class Request{
public long duration;
public Request(long duration){
this.duration=duration;
}
}
}

View File

@@ -0,0 +1,19 @@
package org.joinmastodon.android.api.requests.accounts;
import org.joinmastodon.android.api.MastodonAPIRequest;
import org.joinmastodon.android.model.Relationship;
public class SetPrivateNote extends MastodonAPIRequest<Relationship>{
public SetPrivateNote(String id, String comment){
super(MastodonAPIRequest.HttpMethod.POST, "/accounts/"+id+"/note", Relationship.class);
Request req = new Request(comment);
setRequestBody(req);
}
private static class Request{
public String comment;
public Request(String comment){
this.comment=comment;
}
}
}

View File

@@ -0,0 +1,11 @@
package org.joinmastodon.android.api.requests.announcements;
import org.joinmastodon.android.api.MastodonAPIRequest;
import org.joinmastodon.android.model.Status;
public class AddAnnouncementReaction extends MastodonAPIRequest<Object> {
public AddAnnouncementReaction(String id, String emoji) {
super(HttpMethod.PUT, "/announcements/" + id + "/reactions/" + emoji, Object.class);
setRequestBody(new Object());
}
}

View File

@@ -0,0 +1,9 @@
package org.joinmastodon.android.api.requests.announcements;
import org.joinmastodon.android.api.MastodonAPIRequest;
public class DeleteAnnouncementReaction extends MastodonAPIRequest<Object> {
public DeleteAnnouncementReaction(String id, String emoji) {
super(HttpMethod.DELETE, "/announcements/" + id + "/reactions/" + emoji, Object.class);
}
}

View File

@@ -0,0 +1,10 @@
package org.joinmastodon.android.api.requests.announcements;
import org.joinmastodon.android.api.MastodonAPIRequest;
public class DismissAnnouncement extends MastodonAPIRequest<Object>{
public DismissAnnouncement(String id){
super(HttpMethod.POST, "/announcements/" + id + "/dismiss", Object.class);
setRequestBody(new Object());
}
}

View File

@@ -0,0 +1,15 @@
package org.joinmastodon.android.api.requests.announcements;
import com.google.gson.reflect.TypeToken;
import org.joinmastodon.android.api.MastodonAPIRequest;
import org.joinmastodon.android.model.Announcement;
import java.util.List;
public class GetAnnouncements extends MastodonAPIRequest<List<Announcement>> {
public GetAnnouncements(boolean withDismissed) {
super(MastodonAPIRequest.HttpMethod.GET, "/announcements", new TypeToken<>(){});
addQueryParameter("with_dismissed", withDismissed ? "true" : "false");
}
}

View File

@@ -0,0 +1,16 @@
package org.joinmastodon.android.api.requests.instance;
import com.google.gson.reflect.TypeToken;
import org.joinmastodon.android.api.MastodonAPIRequest;
import org.joinmastodon.android.model.DomainBlock;
import org.joinmastodon.android.model.ExtendedDescription;
import java.util.List;
public class GetDomainBlocks extends MastodonAPIRequest<List<DomainBlock>>{
public GetDomainBlocks(){
super(HttpMethod.GET, "/instance/domain_blocks", new TypeToken<>(){});
}
}

View File

@@ -0,0 +1,12 @@
package org.joinmastodon.android.api.requests.instance;
import org.joinmastodon.android.api.MastodonAPIRequest;
import org.joinmastodon.android.model.ExtendedDescription;
import org.joinmastodon.android.model.Instance;
public class GetExtendedDescription extends MastodonAPIRequest<ExtendedDescription>{
public GetExtendedDescription(){
super(HttpMethod.GET, "/instance/extended_description", ExtendedDescription.class);
}
}

View File

@@ -7,4 +7,15 @@ public class GetInstance extends MastodonAPIRequest<Instance>{
public GetInstance(){
super(HttpMethod.GET, "/instance", Instance.class);
}
public static class V2 extends MastodonAPIRequest<Instance.V2>{
public V2(){
super(HttpMethod.GET, "/instance", Instance.V2.class);
}
@Override
protected String getPathPrefix() {
return "/api/v2";
}
}
}

View File

@@ -0,0 +1,15 @@
package org.joinmastodon.android.api.requests.instance;
import com.google.gson.reflect.TypeToken;
import org.joinmastodon.android.api.MastodonAPIRequest;
import org.joinmastodon.android.model.WeeklyActivity;
import java.util.List;
public class GetWeeklyActivity extends MastodonAPIRequest<List<WeeklyActivity>>{
public GetWeeklyActivity(){
super(HttpMethod.GET, "/instance/activity", new TypeToken<>(){});
}
}

View File

@@ -0,0 +1,17 @@
package org.joinmastodon.android.api.requests.lists;
import org.joinmastodon.android.api.MastodonAPIRequest;
import java.util.List;
public class AddList extends MastodonAPIRequest<Object> {
public AddList(String listName){
super(HttpMethod.POST, "/lists", Object.class);
Request req = new Request();
req.title = listName;
setRequestBody(req);
}
public static class Request{
public String title;
}
}

View File

@@ -0,0 +1,17 @@
package org.joinmastodon.android.api.requests.lists;
import org.joinmastodon.android.api.MastodonAPIRequest;
import java.util.List;
public class EditListName extends MastodonAPIRequest<Object> {
public EditListName(String newListName, String listId){
super(HttpMethod.PUT, "/lists/"+listId, Object.class);
Request req = new Request();
req.title = newListName;
setRequestBody(req);
}
public static class Request{
public String title;
}
}

View File

@@ -0,0 +1,10 @@
package org.joinmastodon.android.api.requests.lists;
import org.joinmastodon.android.api.MastodonAPIRequest;
import org.joinmastodon.android.model.ListTimeline;
public class GetList extends MastodonAPIRequest<ListTimeline> {
public GetList(String id) {
super(HttpMethod.GET, "/lists/" + id, ListTimeline.class);
}
}

View File

@@ -4,11 +4,15 @@ import com.google.gson.reflect.TypeToken;
import org.joinmastodon.android.api.MastodonAPIRequest;
import org.joinmastodon.android.model.FollowList;
import org.joinmastodon.android.model.ListTimeline;
import java.util.List;
public class GetLists extends MastodonAPIRequest<List<FollowList>>{
public GetLists(){
super(HttpMethod.GET, "/lists", new TypeToken<>(){});
}
public GetLists() {
super(HttpMethod.GET, "/lists", new TypeToken<>(){});
}
public GetLists(String accountID) {
super(HttpMethod.GET, "/accounts/"+accountID+"/lists", new TypeToken<>(){});
}
}

View File

@@ -0,0 +1,10 @@
package org.joinmastodon.android.api.requests.lists;
import org.joinmastodon.android.api.MastodonAPIRequest;
import java.util.List;
public class RemoveList extends MastodonAPIRequest<Object> {
public RemoveList(String listId){
super(HttpMethod.DELETE, "/lists/"+listId, Object.class);
}
}

View File

@@ -0,0 +1,17 @@
package org.joinmastodon.android.api.requests.notifications;
import com.google.gson.reflect.TypeToken;
import org.joinmastodon.android.api.ApiUtils;
import org.joinmastodon.android.api.MastodonAPIRequest;
import org.joinmastodon.android.model.Notification;
import java.util.EnumSet;
import java.util.List;
public class DismissNotification extends MastodonAPIRequest<Object>{
public DismissNotification(String id){
super(HttpMethod.POST, "/notifications/" + (id != null ? id + "/dismiss" : "clear"), Object.class);
setRequestBody(new Object());
}
}

View File

@@ -1,6 +1,5 @@
package org.joinmastodon.android.api.requests.notifications;
import com.google.gson.annotations.SerializedName;
import com.google.gson.reflect.TypeToken;
import org.joinmastodon.android.api.ApiUtils;
@@ -11,18 +10,24 @@ import java.util.EnumSet;
import java.util.List;
public class GetNotifications extends MastodonAPIRequest<List<Notification>>{
public GetNotifications(String maxID, int limit, EnumSet<Notification.Type> includeTypes){
public GetNotifications(String maxID, int limit, EnumSet<Notification.Type> includeTypes, boolean isPleromaInstance){
super(HttpMethod.GET, "/notifications", new TypeToken<>(){});
if(maxID!=null)
addQueryParameter("max_id", maxID);
if(limit>0)
addQueryParameter("limit", ""+limit);
if(includeTypes!=null){
for(String type:ApiUtils.enumSetToStrings(includeTypes, Notification.Type.class)){
addQueryParameter("types[]", type);
}
for(String type:ApiUtils.enumSetToStrings(EnumSet.complementOf(includeTypes), Notification.Type.class)){
addQueryParameter("exclude_types[]", type);
if(!isPleromaInstance) {
for(String type:ApiUtils.enumSetToStrings(includeTypes, Notification.Type.class)){
addQueryParameter("types[]", type);
}
for(String type:ApiUtils.enumSetToStrings(EnumSet.complementOf(includeTypes), Notification.Type.class)){
addQueryParameter("exclude_types[]", type);
}
}else{
for(String type:ApiUtils.enumSetToStrings(includeTypes, Notification.Type.class)){
addQueryParameter("include_types[]", type);
}
}
}
removeUnsupportedItems=true;

View File

@@ -0,0 +1,30 @@
package org.joinmastodon.android.api.requests.notifications;
import android.text.TextUtils;
import com.google.gson.reflect.TypeToken;
import org.joinmastodon.android.api.MastodonAPIRequest;
import org.joinmastodon.android.model.Notification;
import java.util.List;
import okhttp3.MultipartBody;
import okhttp3.RequestBody;
public class PleromaMarkNotificationsRead extends MastodonAPIRequest<List<Notification>> {
private final String maxID;
public PleromaMarkNotificationsRead(String maxID) {
super(HttpMethod.POST, "/pleroma/notifications/read", new TypeToken<>(){});
this.maxID = maxID;
}
@Override
public RequestBody getRequestBody() {
MultipartBody.Builder builder=new MultipartBody.Builder()
.setType(MultipartBody.FORM);
if(!TextUtils.isEmpty(maxID))
builder.addFormDataPart("max_id", maxID);
return builder.build();
}
}

View File

@@ -4,10 +4,10 @@ import org.joinmastodon.android.api.MastodonAPIRequest;
import org.joinmastodon.android.model.PushSubscription;
public class RegisterForPushNotifications extends MastodonAPIRequest<PushSubscription>{
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;

View File

@@ -11,9 +11,9 @@ public class CreateOAuthApp extends MastodonAPIRequest<Application>{
}
private static class Request{
public String clientName="Mastodon for Android";
public String clientName="Moshidon";
public String redirectUris=AccountSessionManager.REDIRECT_URI;
public String scopes=AccountSessionManager.SCOPE;
public String website="https://app.joinmastodon.org/android";
public String website="https://github.com/LucasGGamerM/moshidon";
}
}

View File

@@ -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<Status> {
public AddStatusReaction(String id, String emoji) {
super(HttpMethod.POST, "/statuses/" + id + "/react/" + emoji, Status.class);
setRequestBody(new Object());
}
}

View File

@@ -0,0 +1,11 @@
package org.joinmastodon.android.api.requests.statuses;
import org.joinmastodon.android.api.MastodonAPIRequest;
import org.joinmastodon.android.model.AkkomaTranslation;
public class AkkomaTranslateStatus extends MastodonAPIRequest<AkkomaTranslation>{
public AkkomaTranslateStatus(String id, String lang){
super(HttpMethod.GET, "/statuses/"+id+"/translations/"+lang.toUpperCase(), AkkomaTranslation.class);
}
}

View File

@@ -1,6 +1,8 @@
package org.joinmastodon.android.api.requests.statuses;
import org.joinmastodon.android.api.MastodonAPIRequest;
import org.joinmastodon.android.model.ContentType;
import org.joinmastodon.android.model.ScheduledStatus;
import org.joinmastodon.android.model.Status;
import org.joinmastodon.android.model.StatusPrivacy;
@@ -9,12 +11,27 @@ import java.util.ArrayList;
import java.util.List;
public class CreateStatus extends MastodonAPIRequest<Status>{
public static long EPOCH_OF_THE_YEAR_FIVE_THOUSAND=95617584000000L;
public static final Instant DRAFTS_AFTER_INSTANT=Instant.ofEpochMilli(EPOCH_OF_THE_YEAR_FIVE_THOUSAND - 1) /* end of 4999 */;
public static Instant getDraftInstant() {
return DRAFTS_AFTER_INSTANT.plusMillis(System.currentTimeMillis());
}
public CreateStatus(CreateStatus.Request req, String uuid){
super(HttpMethod.POST, "/statuses", Status.class);
setRequestBody(req);
addHeader("Idempotency-Key", uuid);
}
public static class Scheduled extends MastodonAPIRequest<ScheduledStatus>{
public Scheduled(CreateStatus.Request req, String uuid){
super(HttpMethod.POST, "/statuses", ScheduledStatus.class);
setRequestBody(req);
addHeader("Idempotency-Key", uuid);
}
}
public static class Request{
public String status;
public List<MediaAttribute> mediaAttributes;
@@ -22,11 +39,17 @@ public class CreateStatus extends MastodonAPIRequest<Status>{
public Poll poll;
public String inReplyToId;
public boolean sensitive;
public boolean localOnly;
public String spoilerText;
public StatusPrivacy visibility;
public Instant scheduledAt;
public String language;
public String quoteId;
public ContentType contentType;
public boolean preview;
public static class Poll{
public ArrayList<String> options=new ArrayList<>();
public int expiresIn;

View File

@@ -7,4 +7,10 @@ public class DeleteStatus extends MastodonAPIRequest<Status>{
public DeleteStatus(String id){
super(HttpMethod.DELETE, "/statuses/"+id, Status.class);
}
public static class Scheduled extends MastodonAPIRequest<Object> {
public Scheduled(String id) {
super(HttpMethod.DELETE, "/scheduled_statuses/"+id, Object.class);
}
}
}

View File

@@ -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<Status> {
public DeleteStatusReaction(String id, String emoji) {
super(HttpMethod.POST, "/statuses/" + id + "/unreact/" + emoji, Status.class);
setRequestBody(new Object());
}
}

View File

@@ -0,0 +1,16 @@
package org.joinmastodon.android.api.requests.statuses;
import com.google.gson.reflect.TypeToken;
import org.joinmastodon.android.api.requests.HeaderPaginationRequest;
import org.joinmastodon.android.model.ScheduledStatus;
public class GetScheduledStatuses extends HeaderPaginationRequest<ScheduledStatus>{
public GetScheduledStatuses(String maxID, int limit){
super(HttpMethod.GET, "/scheduled_statuses", new TypeToken<>(){});
if(maxID!=null)
addQueryParameter("max_id", maxID);
if(limit>0)
addQueryParameter("limit", limit+"");
}
}

View File

@@ -2,17 +2,22 @@ package org.joinmastodon.android.api.requests.statuses;
import org.joinmastodon.android.api.AllFieldsAreRequired;
import org.joinmastodon.android.api.MastodonAPIRequest;
import org.joinmastodon.android.api.RequiredField;
import org.joinmastodon.android.model.BaseModel;
import org.joinmastodon.android.model.ContentType;
public class GetStatusSourceText extends MastodonAPIRequest<GetStatusSourceText.Response>{
public GetStatusSourceText(String id){
super(HttpMethod.GET, "/statuses/"+id+"/source", Response.class);
}
@AllFieldsAreRequired
public static class Response extends BaseModel{
@RequiredField
public String id;
@RequiredField
public String text;
@RequiredField
public String spoilerText;
public ContentType contentType;
}
}

View File

@@ -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<Status> {
public PleromaAddStatusReaction(String id, String emoji) {
super(HttpMethod.PUT, "/pleroma/statuses/" + id + "/reactions/" + emoji, Status.class);
setRequestBody(new Object());
}
}

View File

@@ -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<Status> {
public PleromaDeleteStatusReaction(String id, String emoji) {
super(HttpMethod.DELETE, "/pleroma/statuses/" + id + "/reactions/" + emoji, Status.class);
}
}

View File

@@ -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<List<EmojiReaction>> {
public PleromaGetStatusReactions(String id, String emoji) {
super(HttpMethod.GET, "/pleroma/statuses/" + id + "/reactions/" + (emoji != null ? emoji : ""), new TypeToken<>(){});
}
}

View File

@@ -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 SetStatusMuted extends MastodonAPIRequest<Status>{
public SetStatusMuted(String id, boolean muted){
super(HttpMethod.POST, "/statuses/"+id+"/"+(muted ? "mute" : "unmute"), Status.class);
setRequestBody(new Object());
}
}

View File

@@ -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 SetStatusPinned extends MastodonAPIRequest<Status>{
public SetStatusPinned(String id, boolean pinned){
super(HttpMethod.POST, "/statuses/"+id+"/"+(pinned ? "pin" : "unpin"), Status.class);
setRequestBody(new Object());
}
}

View File

@@ -2,10 +2,17 @@ package org.joinmastodon.android.api.requests.statuses;
import org.joinmastodon.android.api.MastodonAPIRequest;
import org.joinmastodon.android.model.Status;
import org.joinmastodon.android.model.StatusPrivacy;
public class SetStatusReblogged extends MastodonAPIRequest<Status>{
public SetStatusReblogged(String id, boolean reblogged){
public SetStatusReblogged(String id, boolean reblogged, StatusPrivacy visibility){
super(HttpMethod.POST, "/statuses/"+id+"/"+(reblogged ? "reblog" : "unreblog"), Status.class);
setRequestBody(new Object());
Request req = new Request();
req.visibility = visibility;
setRequestBody(req);
}
public static class Request {
public StatusPrivacy visibility;
}
}

View File

@@ -0,0 +1,28 @@
package org.joinmastodon.android.api.requests.tags;
import com.google.gson.reflect.TypeToken;
import org.joinmastodon.android.api.MastodonAPIRequest;
import org.joinmastodon.android.api.requests.HeaderPaginationRequest;
import org.joinmastodon.android.model.Hashtag;
import java.util.List;
public class GetFollowedHashtags extends HeaderPaginationRequest<Hashtag> {
public GetFollowedHashtags() {
this(null, null, -1, null);
}
public GetFollowedHashtags(String maxID, String minID, int limit, String sinceID){
super(HttpMethod.GET, "/followed_tags", new TypeToken<>(){});
if(maxID!=null)
addQueryParameter("max_id", maxID);
if(minID!=null)
addQueryParameter("min_id", minID);
if(sinceID!=null)
addQueryParameter("since_id", sinceID);
if(limit>0)
addQueryParameter("limit", ""+limit);
}
}

View File

@@ -0,0 +1,11 @@
package org.joinmastodon.android.api.requests.tags;
import org.joinmastodon.android.api.MastodonAPIRequest;
import org.joinmastodon.android.model.Hashtag;
public class GetHashtag extends MastodonAPIRequest<Hashtag> {
public GetHashtag(String name){
super(HttpMethod.GET, "/tags/"+name, Hashtag.class);
}
}

View File

@@ -0,0 +1,11 @@
package org.joinmastodon.android.api.requests.tags;
import org.joinmastodon.android.api.MastodonAPIRequest;
import org.joinmastodon.android.model.Hashtag;
public class SetHashtagFollowed extends MastodonAPIRequest<Hashtag>{
public SetHashtagFollowed(String name, boolean followed){
super(HttpMethod.POST, "/tags/"+name+"/"+(followed ? "follow" : "unfollow"), Hashtag.class);
setRequestBody(new Object());
}
}

View File

@@ -0,0 +1,22 @@
package org.joinmastodon.android.api.requests.timelines;
import android.text.TextUtils;
import com.google.gson.reflect.TypeToken;
import org.joinmastodon.android.api.MastodonAPIRequest;
import org.joinmastodon.android.model.Status;
import java.util.List;
public class GetBubbleTimeline extends MastodonAPIRequest<List<Status>> {
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(replyVisibility != null)
addQueryParameter("reply_visibility", replyVisibility);
}
}

View File

@@ -8,13 +8,26 @@ import org.joinmastodon.android.model.Status;
import java.util.List;
public class GetHashtagTimeline extends MastodonAPIRequest<List<Status>>{
public GetHashtagTimeline(String hashtag, String maxID, String minID, int limit){
public GetHashtagTimeline(String hashtag, String maxID, String minID, int limit, List<String> containsAny, List<String> containsAll, List<String> containsNone, boolean localOnly, String replyVisibility){
super(HttpMethod.GET, "/timelines/tag/"+hashtag, new TypeToken<>(){});
if (localOnly)
addQueryParameter("local", "true");
if(maxID!=null)
addQueryParameter("max_id", maxID);
if(minID!=null)
addQueryParameter("min_id", minID);
if(limit>0)
addQueryParameter("limit", ""+limit);
if(containsAny!=null)
for (String tag : containsAny)
addQueryParameter("any[]", tag);
if(containsAll!=null)
for (String tag : containsAll)
addQueryParameter("all[]", tag);
if(containsNone!=null)
for (String tag : containsNone)
addQueryParameter("none[]", tag);
if(replyVisibility != null)
addQueryParameter("reply_visibility", replyVisibility);
}
}

View File

@@ -8,7 +8,7 @@ import org.joinmastodon.android.model.Status;
import java.util.List;
public class GetHomeTimeline extends MastodonAPIRequest<List<Status>>{
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);
@@ -18,5 +18,7 @@ public class GetHomeTimeline extends MastodonAPIRequest<List<Status>>{
addQueryParameter("since_id", sinceID);
if(limit>0)
addQueryParameter("limit", ""+limit);
if(replyVisibility != null)
addQueryParameter("reply_visibility", replyVisibility);
}
}

View File

@@ -7,16 +7,18 @@ import org.joinmastodon.android.model.Status;
import java.util.List;
public class GetListTimeline extends MastodonAPIRequest<List<Status>>{
public GetListTimeline(String listID, String maxID, String minID, int limit, String sinceID){
super(HttpMethod.GET, "/timelines/list/"+listID, new TypeToken<>(){});
if(maxID!=null)
addQueryParameter("max_id", maxID);
if(minID!=null)
addQueryParameter("min_id", minID);
if(limit>0)
addQueryParameter("limit", ""+limit);
if(sinceID!=null)
addQueryParameter("since_id", sinceID);
}
public class GetListTimeline extends MastodonAPIRequest<List<Status>> {
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);
if(minID!=null)
addQueryParameter("min_id", minID);
if(limit>0)
addQueryParameter("limit", ""+limit);
if(sinceID!=null)
addQueryParameter("since_id", sinceID);
if(replyVisibility != null)
addQueryParameter("reply_visibility", replyVisibility);
}
}

View File

@@ -10,7 +10,7 @@ import org.joinmastodon.android.model.Status;
import java.util.List;
public class GetPublicTimeline extends MastodonAPIRequest<List<Status>>{
public GetPublicTimeline(boolean local, boolean remote, String maxID, String minID, int limit, String sinceID){
public GetPublicTimeline(boolean local, boolean remote, String maxID, String minID, int limit, String sinceID, String replyVisibility){
super(HttpMethod.GET, "/timelines/public", new TypeToken<>(){});
if(local)
addQueryParameter("local", "true");
@@ -24,5 +24,7 @@ public class GetPublicTimeline extends MastodonAPIRequest<List<Status>>{
addQueryParameter("since_id", sinceID);
if(limit>0)
addQueryParameter("limit", limit+"");
if(replyVisibility != null)
addQueryParameter("reply_visibility", replyVisibility);
}
}

View File

@@ -1,23 +1,95 @@
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 androidx.annotation.StringRes;
import com.google.gson.reflect.TypeToken;
import org.joinmastodon.android.GlobalUserPreferences;
import org.joinmastodon.android.R;
import org.joinmastodon.android.model.ContentType;
import org.joinmastodon.android.model.Emoji;
import org.joinmastodon.android.model.Emoji;
import org.joinmastodon.android.model.PushSubscription;
import org.joinmastodon.android.model.TimelineDefinition;
import java.lang.reflect.Type;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
public class AccountLocalPreferences{
private final SharedPreferences prefs;
public boolean showInteractionCounts;
public boolean customEmojiInNames;
public boolean showCWs;
public boolean revealCWs;
public boolean hideSensitiveMedia;
public boolean serverSideFiltersSupported;
public AccountLocalPreferences(SharedPreferences prefs){
// MEGALODON
public boolean showReplies;
public boolean showBoosts;
public ArrayList<String> recentLanguages;
public boolean bottomEncoding;
public ContentType defaultContentType;
public boolean contentTypesEnabled;
public ArrayList<TimelineDefinition> timelines;
public boolean localOnlySupported;
public boolean glitchInstance;
public String publishButtonText;
public String timelineReplyVisibility; // akkoma-only
public boolean keepOnlyLatestNotification;
public boolean emojiReactionsEnabled;
public ShowEmojiReactions showEmojiReactions;
public ColorPreference color;
public ArrayList<Emoji> recentCustomEmoji;
private final static Type recentLanguagesType=new TypeToken<ArrayList<String>>() {}.getType();
private final static Type timelinesType=new TypeToken<ArrayList<TimelineDefinition>>() {}.getType();
private final static Type recentCustomEmojiType=new TypeToken<ArrayList<Emoji>>() {}.getType();
// MOSHIDON
// private final static Type recentEmojisType = new TypeToken<Map<String, Integer>>() {}.getType();
// public Map<String, Integer> recentEmojis;
private final static Type notificationFiltersType = new TypeToken<PushSubscription.Alerts>() {}.getType();
public PushSubscription.Alerts notificationFilters;
public AccountLocalPreferences(SharedPreferences prefs, AccountSession session){
this.prefs=prefs;
showInteractionCounts=prefs.getBoolean("interactionCounts", true);
showInteractionCounts=prefs.getBoolean("interactionCounts", false);
customEmojiInNames=prefs.getBoolean("emojiInNames", true);
showCWs=prefs.getBoolean("showCWs", 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());
showEmojiReactions=ShowEmojiReactions.valueOf(prefs.getString("showEmojiReactions", ShowEmojiReactions.HIDE_EMPTY.name()));
color=prefs.contains("color") ? ColorPreference.valueOf(prefs.getString("color", null)) : null;
recentCustomEmoji=fromJson(prefs.getString("recentCustomEmoji", null), recentCustomEmojiType, new ArrayList<>());
// MOSHIDON
// recentEmojis=fromJson(prefs.getString("recentEmojis", "{}"), recentEmojisType, new HashMap<>());
notificationFilters=fromJson(prefs.getString("notificationFilters", gson.toJson(PushSubscription.Alerts.ofAll())), notificationFiltersType, PushSubscription.Alerts.ofAll());
}
public long getNotificationsPauseEndTime(){
@@ -28,13 +100,73 @@ public class AccountLocalPreferences{
prefs.edit().putLong("notificationsPauseTime", time).apply();
}
public ColorPreference getCurrentColor(){
return color!=null ? color : GlobalUserPreferences.color!=null ? GlobalUserPreferences.color : ColorPreference.MATERIAL3;
}
public void save(){
prefs.edit()
.putBoolean("interactionCounts", showInteractionCounts)
.putBoolean("emojiInNames", customEmojiInNames)
.putBoolean("showCWs", showCWs)
.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)
.putString("showEmojiReactions", showEmojiReactions.name())
.putString("color", color!=null ? color.name() : null)
.putString("recentCustomEmoji", gson.toJson(recentCustomEmoji))
// MOSHIDON
// .putString("recentEmojis", gson.toJson(recentEmojis))
.putString("notificationFilters", gson.toJson(notificationFilters))
.apply();
}
public enum ColorPreference{
MATERIAL3,
PURPLE,
PINK,
GREEN,
BLUE,
BROWN,
RED,
YELLOW,
NORD,
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 ShowEmojiReactions{
HIDE_EMPTY,
ONLY_OPENED,
ALWAYS
}
}

View File

@@ -3,6 +3,7 @@ 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;
@@ -24,6 +25,7 @@ import org.joinmastodon.android.model.Application;
import org.joinmastodon.android.model.FilterAction;
import org.joinmastodon.android.model.FilterContext;
import org.joinmastodon.android.model.FilterResult;
import org.joinmastodon.android.model.Instance;
import org.joinmastodon.android.model.FollowList;
import org.joinmastodon.android.model.LegacyFilter;
import org.joinmastodon.android.model.Preferences;
@@ -36,6 +38,8 @@ import org.joinmastodon.android.utils.ObjectIdComparator;
import java.io.File;
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;
@@ -62,7 +66,7 @@ public class AccountSession{
public AccountActivationInfo activationInfo;
public Preferences preferences;
private transient MastodonAPIController apiController;
private transient StatusInteractionController statusInteractionController;
private transient StatusInteractionController statusInteractionController, remoteStatusInteractionController;
private transient CacheController cacheController;
private transient PushSubscriptionManager pushSubscriptionManager;
private transient SharedPreferences prefs;
@@ -98,6 +102,12 @@ public class AccountSession{
return statusInteractionController;
}
public StatusInteractionController getRemoteStatusInteractionController(){
if(remoteStatusInteractionController==null)
remoteStatusInteractionController=new StatusInteractionController(getID(), false);
return remoteStatusInteractionController;
}
public CacheController getCacheController(){
if(cacheController==null)
cacheController=new CacheController(getID());
@@ -114,12 +124,22 @@ public class AccountSession{
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<Preferences> 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();
@@ -128,6 +148,9 @@ public class AccountSession{
@Override
public void onError(ErrorResponse error){
Log.w(TAG, "Failed to load preferences for account "+getID()+": "+error);
if (preferences==null)
preferences=new Preferences();
preferencesFromAccountSource(self);
}
})
.exec(getID());
@@ -198,7 +221,7 @@ public class AccountSession{
public void savePreferencesIfPending(){
if(preferencesNeedSaving){
new UpdateAccountCredentialsPreferences(preferences, null, self.discoverable, self.source.indexable)
new UpdateAccountCredentialsPreferences(preferences, self.locked, self.discoverable, self.source.indexable)
.setCallback(new Callback<>(){
@Override
public void onSuccess(Account result){
@@ -218,57 +241,112 @@ public class AccountSession{
public AccountLocalPreferences getLocalPreferences(){
if(localPreferences==null)
localPreferences=new AccountLocalPreferences(getRawLocalPreferences());
localPreferences=new AccountLocalPreferences(getRawLocalPreferences(), this);
return localPreferences;
}
public void filterStatuses(List<Status> statuses, FilterContext context){
filterStatusContainingObjects(statuses, Function.identity(), context);
filterStatuses(statuses, context, null);
}
public void filterStatuses(List<Status> statuses, FilterContext context, Account profile){
filterStatusContainingObjects(statuses, Function.identity(), context, profile);
}
public <T> void filterStatusContainingObjects(List<T> objects, Function<T, Status> extractor, FilterContext context){
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;
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){
filterStatusContainingObjects(objects, extractor, context, null);
}
private boolean statusIsOnOwnProfile(Status s, Account profile){
return self != null && profile != null && s.account != null
&& Objects.equals(self.id, profile.id) && Objects.equals(self.id, s.account.id);
}
private boolean isFilteredType(Status s){
AccountLocalPreferences localPreferences = getLocalPreferences();
return (!localPreferences.showReplies && s.inReplyToId != null)
|| (!localPreferences.showBoosts && s.reblog != null);
}
public <T> void filterStatusContainingObjects(List<T> objects, Function<T, Status> extractor, FilterContext context, Account profile){
AccountLocalPreferences localPreferences = getLocalPreferences();
if(!localPreferences.serverSideFiltersSupported) for(T obj:objects){
Status s=extractor.apply(obj);
if(s!=null && s.filtered!=null){
getLocalPreferences().serverSideFiltersSupported=true;
getLocalPreferences().save();
return;
localPreferences.serverSideFiltersSupported=true;
localPreferences.save();
break;
}
}
objects.removeIf(o->{
Status s=extractor.apply(o);
if(s==null)
return false;
for(LegacyFilter filter:wordFilters){
List<T> removeUs=new ArrayList<>();
for(int i=0; i<objects.size(); i++){
T o=objects.get(i);
if(filterStatusContainingObject(o, extractor, context, profile)){
Status s=extractor.apply(o);
removeUs.add(o);
if(s!=null && s.hasGapAfter!=null && i>0){
// oops, we're about to remove an item that has a gap after...
// gotta find the previous status that's not also about to be removed
for(int j=i-1; j>=0; j--){
T p=objects.get(j);
Status prev=extractor.apply(objects.get(j));
if(prev!=null && !removeUs.contains(p)){
prev.hasGapAfter=s.hasGapAfter;
break;
}
}
}
}
}
objects.removeAll(removeUs);
}
public <T> boolean filterStatusContainingObject(T object, Function<T, Status> extractor, FilterContext context, Account profile){
Status s=extractor.apply(object);
if(s==null)
return false;
// don't hide own posts in own profile
if(statusIsOnOwnProfile(s, profile))
return false;
if(isFilteredType(s) && (context == FilterContext.HOME || context == FilterContext.PUBLIC))
return true;
// Even with server-side filters, clients are expected to remove statuses that match a filter that hides them
if(getLocalPreferences().serverSideFiltersSupported){
for(FilterResult filter : s.filtered){
if(filter.filter.isActive() && filter.filter.filterAction==FilterAction.HIDE)
return true;
}
}else if(wordFilters!=null){
for(LegacyFilter filter : wordFilters){
if(filter.context.contains(context) && filter.matches(s) && filter.isActive())
return true;
}
return false;
});
}
return false;
}
public void updateAccountInfo(){
AccountSessionManager.getInstance().updateSessionLocalInfo(this);
}
public Optional<Instance> getInstance() {
return Optional.ofNullable(AccountSessionManager.getInstance().getInstanceInfo(domain));
}
public Uri getInstanceUri() {
return new Uri.Builder()
.scheme("https")
.authority(getInstance().map(i -> i.normalizedUri).orElse(domain))
.build();
}
public String getDefaultAvatarUrl() {
return getInstance()
.map(instance->"https://"+domain+(instance.isAkkoma() ? "/images/avi.png" : "/avatars/original/missing.png"))
.orElse("");
}
public boolean isNotificationsMentionsOnly(){
return getRawLocalPreferences().getBoolean("notificationsMentionsOnly", false);
}

View File

@@ -1,5 +1,7 @@
package org.joinmastodon.android.api.session;
import static org.unifiedpush.android.connector.UnifiedPush.getDistributor;
import android.app.Activity;
import android.app.NotificationManager;
import android.content.ComponentName;
@@ -15,6 +17,7 @@ import android.util.Log;
import org.joinmastodon.android.BuildConfig;
import org.joinmastodon.android.E;
import org.joinmastodon.android.GlobalUserPreferences;
import org.joinmastodon.android.MainActivity;
import org.joinmastodon.android.MastodonApp;
import org.joinmastodon.android.R;
@@ -33,6 +36,7 @@ import org.joinmastodon.android.model.EmojiCategory;
import org.joinmastodon.android.model.LegacyFilter;
import org.joinmastodon.android.model.Instance;
import org.joinmastodon.android.model.Token;
import org.unifiedpush.android.connector.UnifiedPush;
import java.io.File;
import java.io.FileInputStream;
@@ -47,6 +51,7 @@ import java.util.Comparator;
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;
@@ -59,7 +64,7 @@ import me.grishka.appkit.api.ErrorResponse;
public class AccountSessionManager{
private static final String TAG="AccountSessionManager";
public static final String SCOPE="read write follow push";
public static final String REDIRECT_URI="mastodon-android-auth://callback";
public static final String REDIRECT_URI = getRedirectURI();
private static final AccountSessionManager instance=new AccountSessionManager();
@@ -78,6 +83,17 @@ public class AccountSessionManager{
return instance;
}
public static String getRedirectURI() {
StringBuilder builder = new StringBuilder();
builder.append("moshidon-android-");
if (BuildConfig.BUILD_TYPE.equals("debug") || BuildConfig.BUILD_TYPE.equals("nightly")) {
builder.append(BuildConfig.BUILD_TYPE);
builder.append('-');
}
builder.append("auth://callback");
return builder.toString();
}
private AccountSessionManager(){
prefs=MastodonApp.context.getSharedPreferences("account_manager", Context.MODE_PRIVATE);
File file=new File(MastodonApp.context.getFilesDir(), "accounts.json");
@@ -99,27 +115,43 @@ public class AccountSessionManager{
}
public void addAccount(Instance instance, Token token, Account self, Application app, AccountActivationInfo activationInfo){
Context context = MastodonApp.context;
instances.put(instance.uri, instance);
AccountSession session=new AccountSession(token, self, app, instance.uri, activationInfo==null, activationInfo);
sessions.put(session.getID(), session);
lastActiveAccountID=session.getID();
writeAccountsFile();
updateInstanceEmojis(instance, instance.uri);
if(PushSubscriptionManager.arePushNotificationsAvailable()){
// write initial instance info to file immediately to avoid sessions without instance info
InstanceInfoStorageWrapper wrapper = new InstanceInfoStorageWrapper();
wrapper.instance = instance;
MastodonAPIController.runInBackground(()->writeInstanceInfoFile(wrapper, instance.uri));
updateMoreInstanceInfo(instance, instance.uri);
if (!UnifiedPush.getDistributor(context).isEmpty()) {
UnifiedPush.registerApp(
context,
session.getID(),
new ArrayList<>(),
context.getPackageName()
);
} else if(PushSubscriptionManager.arePushNotificationsAvailable()){
session.getPushSubscriptionManager().registerAccountForPush(null);
}
maybeUpdateShortcuts();
}
public synchronized void writeAccountsFile(){
File file=new File(MastodonApp.context.getFilesDir(), "accounts.json");
File tmpFile = new File(MastodonApp.context.getFilesDir(), "accounts.json~");
File file = new File(MastodonApp.context.getFilesDir(), "accounts.json");
try{
try(FileOutputStream out=new FileOutputStream(file)){
try(FileOutputStream out=new FileOutputStream(tmpFile)){
SessionsStorageWrapper w=new SessionsStorageWrapper();
w.accounts=new ArrayList<>(sessions.values());
OutputStreamWriter writer=new OutputStreamWriter(out, StandardCharsets.UTF_8);
MastodonAPIController.gson.toJson(w, writer);
writer.flush();
if (!tmpFile.renameTo(file)) Log.e(TAG, "Error renaming " + tmpFile.getPath() + " to " + file.getPath());
}
}catch(IOException x){
Log.e(TAG, "Error writing accounts file", x);
@@ -149,6 +181,15 @@ public class AccountSessionManager{
return sessions.get(id);
}
public static Optional<AccountSession> getOptional(String id) {
return Optional.ofNullable(getInstance().tryGetAccount(id));
}
@Nullable
public AccountSession tryGetAccount(Account account) {
return sessions.get(account.getDomainFromURL() + "_" + account.id);
}
@Nullable
public AccountSession getLastActiveAccount(){
if(sessions.isEmpty() || lastActiveAccountID==null)
@@ -225,7 +266,7 @@ public class AccountSessionManager{
.path("/oauth/authorize")
.appendQueryParameter("response_type", "code")
.appendQueryParameter("client_id", result.clientId)
.appendQueryParameter("redirect_uri", "mastodon-android-auth://callback")
.appendQueryParameter("redirect_uri", REDIRECT_URI)
.appendQueryParameter("scope", SCOPE)
.build();
@@ -258,27 +299,32 @@ public class AccountSessionManager{
}
public void maybeUpdateLocalInfo(){
maybeUpdateLocalInfo(null);
}
public void maybeUpdateLocalInfo(AccountSession activeSession){
long now=System.currentTimeMillis();
HashSet<String> domains=new HashSet<>();
for(AccountSession session:sessions.values()){
domains.add(session.domain.toLowerCase());
if(now-session.infoLastUpdated>24L*3600_000L){
if(session == activeSession || now-session.infoLastUpdated>24L*3600_000L){
session.reloadPreferences(null);
updateSessionLocalInfo(session);
}
if(!session.getLocalPreferences().serverSideFiltersSupported && now-session.filtersLastUpdated>3600_000L){
if(session == activeSession || (session.getLocalPreferences().serverSideFiltersSupported && now-session.filtersLastUpdated>3600_000L)){
updateSessionWordFilters(session);
}
}
if(loadedInstances){
maybeUpdateCustomEmojis(domains);
maybeUpdateCustomEmojis(domains, activeSession != null ? activeSession.domain : null);
}
}
private void maybeUpdateCustomEmojis(Set<String> domains){
private void maybeUpdateCustomEmojis(Set<String> domains, String activeDomain){
long now=System.currentTimeMillis();
for(String domain:domains){
Long lastUpdated=instancesLastUpdated.get(domain);
if(lastUpdated==null || now-lastUpdated>24L*3600_000L){
if(domain.equals(activeDomain) || lastUpdated==null || now-lastUpdated>24L*3600_000L){
updateInstanceInfo(domain);
}
}
@@ -291,6 +337,7 @@ public class AccountSessionManager{
public void onSuccess(Account result){
session.self=result;
session.infoLastUpdated=System.currentTimeMillis();
session.preferencesFromAccountSource(result);
writeAccountsFile();
}
@@ -326,7 +373,7 @@ public class AccountSessionManager{
@Override
public void onSuccess(Instance instance){
instances.put(domain, instance);
updateInstanceEmojis(instance, domain);
updateMoreInstanceInfo(instance, domain);
}
@Override
@@ -337,6 +384,21 @@ public class AccountSessionManager{
.execNoAuth(domain);
}
public void updateMoreInstanceInfo(Instance instance, String domain) {
new GetInstance.V2().setCallback(new Callback<>() {
@Override
public void onSuccess(Instance.V2 v2) {
if (instance != null) instance.v2 = v2;
updateInstanceEmojis(instance, domain);
}
@Override
public void onError(ErrorResponse errorResponse) {
updateInstanceEmojis(instance, domain);
}
}).execNoAuth(instance.uri);
}
private void updateInstanceEmojis(Instance instance, String domain){
new GetCustomEmojis()
.setCallback(new Callback<>(){
@@ -354,7 +416,9 @@ public class AccountSessionManager{
@Override
public void onError(ErrorResponse error){
InstanceInfoStorageWrapper wrapper=new InstanceInfoStorageWrapper();
wrapper.instance = instance;
MastodonAPIController.runInBackground(()->writeInstanceInfoFile(wrapper, domain));
}
})
.execNoAuth(domain);
@@ -365,10 +429,13 @@ public class AccountSessionManager{
}
private void writeInstanceInfoFile(InstanceInfoStorageWrapper emojis, String domain){
try(FileOutputStream out=new FileOutputStream(getInstanceInfoFile(domain))){
File file = getInstanceInfoFile(domain);
File tmpFile = new File(file.getPath() + "~");
try(FileOutputStream out=new FileOutputStream(tmpFile)){
OutputStreamWriter writer=new OutputStreamWriter(out, StandardCharsets.UTF_8);
MastodonAPIController.gson.toJson(emojis, writer);
writer.flush();
if (!tmpFile.renameTo(file)) Log.e(TAG, "Error renaming " + tmpFile.getPath() + " to " + file.getPath());
}catch(IOException x){
Log.w(TAG, "Error writing instance info file for "+domain, x);
}
@@ -388,7 +455,7 @@ public class AccountSessionManager{
}
if(!loadedInstances){
loadedInstances=true;
maybeUpdateCustomEmojis(domains);
maybeUpdateCustomEmojis(domains, null);
}
}

View File

@@ -0,0 +1,19 @@
package org.joinmastodon.android.events;
import androidx.recyclerview.widget.RecyclerView;
import org.joinmastodon.android.model.EmojiReaction;
import java.util.List;
public class EmojiReactionsUpdatedEvent{
public final String id;
public final List<EmojiReaction> reactions;
public final boolean updateTextPadding;
public RecyclerView.ViewHolder viewHolder;
public EmojiReactionsUpdatedEvent(String id, List<EmojiReaction> reactions, boolean updateTextPadding, RecyclerView.ViewHolder viewHolder){
this.id=id;
this.reactions=reactions;
this.updateTextPadding=updateTextPadding;
this.viewHolder=viewHolder;
}
}

View File

@@ -0,0 +1,18 @@
package org.joinmastodon.android.events;
import org.joinmastodon.android.model.Account;
import org.joinmastodon.android.model.Relationship;
public class FollowRequestHandledEvent {
public String accountID;
public boolean accepted;
public Account account;
public Relationship relationship;
public FollowRequestHandledEvent(String accountID, boolean accepted, Account account, Relationship rel){
this.accountID=accountID;
this.accepted=accepted;
this.account=account;
this.relationship=rel;
}
}

View File

@@ -0,0 +1,11 @@
package org.joinmastodon.android.events;
public class HashtagUpdatedEvent {
public final String name;
public final boolean following;
public HashtagUpdatedEvent(String name, boolean following) {
this.name = name;
this.following = following;
}
}

View File

@@ -0,0 +1,17 @@
package org.joinmastodon.android.events;
import org.joinmastodon.android.model.ListTimeline;
public class ListUpdatedCreatedEvent {
public final String id;
public final String title;
public final ListTimeline.RepliesPolicy repliesPolicy;
public final boolean exclusive;
public ListUpdatedCreatedEvent(String id, String title, boolean exclusive, ListTimeline.RepliesPolicy repliesPolicy) {
this.id = id;
this.title = title;
this.exclusive = exclusive;
this.repliesPolicy = repliesPolicy;
}
}

View File

@@ -0,0 +1,9 @@
package org.joinmastodon.android.events;
public class NotificationDeletedEvent{
public final String id;
public NotificationDeletedEvent(String id){
this.id=id;
}
}

View File

@@ -0,0 +1,11 @@
package org.joinmastodon.android.events;
public class ReblogDeletedEvent{
public final String statusID;
public final String accountID;
public ReblogDeletedEvent(String statusID, String accountID){
this.statusID=statusID;
this.accountID=accountID;
}
}

View File

@@ -0,0 +1,13 @@
package org.joinmastodon.android.events;
import org.joinmastodon.android.model.ScheduledStatus;
public class ScheduledStatusCreatedEvent {
public final ScheduledStatus scheduledStatus;
public final String accountID;
public ScheduledStatusCreatedEvent(ScheduledStatus scheduledStatus, String accountID){
this.scheduledStatus = scheduledStatus;
this.accountID=accountID;
}
}

View File

@@ -0,0 +1,11 @@
package org.joinmastodon.android.events;
public class ScheduledStatusDeletedEvent{
public final String id;
public final String accountID;
public ScheduledStatusDeletedEvent(String id, String accountID){
this.id=id;
this.accountID=accountID;
}
}

View File

@@ -5,15 +5,18 @@ import org.joinmastodon.android.model.Status;
public class StatusCountersUpdatedEvent{
public String id;
public long favorites, reblogs, replies;
public boolean favorited, reblogged, bookmarked;
public boolean favorited, reblogged, bookmarked, pinned;
public Status status;
public StatusCountersUpdatedEvent(Status s){
id=s.id;
status=s;
favorites=s.favouritesCount;
reblogs=s.reblogsCount;
replies=s.repliesCount;
favorited=s.favourited;
reblogged=s.reblogged;
bookmarked=s.bookmarked;
pinned=s.pinned;
}
}

View File

@@ -9,5 +9,6 @@ public class StatusCreatedEvent{
public StatusCreatedEvent(Status status, String accountID){
this.status=status;
this.accountID=accountID;
status.fromStatusCreated=true;
}
}

View File

@@ -0,0 +1,15 @@
package org.joinmastodon.android.events;
import org.joinmastodon.android.model.Status;
public class StatusMuteChangedEvent{
public String id;
public boolean muted;
public Status status;
public StatusMuteChangedEvent(Status s){
id=s.id;
muted=s.muted;
status=s;
}
}

View File

@@ -0,0 +1,11 @@
package org.joinmastodon.android.events;
public class StatusUnpinnedEvent {
public final String id;
public final String accountID;
public StatusUnpinnedEvent(String id, String accountID){
this.id=id;
this.accountID=accountID;
}
}

View File

@@ -0,0 +1,6 @@
package org.joinmastodon.android.events;
public class TakePictureRequestEvent {
public TakePictureRequestEvent(){
}
}

View File

@@ -1,47 +1,41 @@
package org.joinmastodon.android.fragments;
import android.app.Activity;
import android.net.Uri;
import android.os.Bundle;
import android.view.View;
import android.widget.HorizontalScrollView;
import android.widget.LinearLayout;
import org.joinmastodon.android.R;
import org.joinmastodon.android.api.requests.accounts.GetAccountStatuses;
import org.joinmastodon.android.api.session.AccountSessionManager;
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.FilterContext;
import org.joinmastodon.android.model.Status;
import org.joinmastodon.android.ui.drawables.EmptyDrawable;
import org.joinmastodon.android.ui.views.FilterChipView;
import org.joinmastodon.android.ui.displayitems.HeaderStatusDisplayItem;
import org.parceler.Parcels;
import java.util.Collections;
import java.util.List;
import java.util.Optional;
import androidx.recyclerview.widget.RecyclerView;
import me.grishka.appkit.api.SimpleCallback;
import me.grishka.appkit.utils.MergeRecyclerAdapter;
import me.grishka.appkit.utils.SingleViewRecyclerAdapter;
import me.grishka.appkit.utils.V;
public class AccountTimelineFragment extends StatusListFragment{
private Account user;
private GetAccountStatuses.Filter filter;
private HorizontalScrollView filtersBar;
private FilterChipView defaultFilter, withRepliesFilter, mediaFilter;
public AccountTimelineFragment(){
setListLayoutId(R.layout.recycler_fragment_no_refresh);
}
public static AccountTimelineFragment newInstance(String accountID, Account profileAccount, boolean load){
public static AccountTimelineFragment newInstance(String accountID, Account profileAccount, GetAccountStatuses.Filter filter, boolean load){
AccountTimelineFragment f=new AccountTimelineFragment();
Bundle args=new Bundle();
args.putString("account", accountID);
args.putParcelable("profileAccount", Parcels.wrap(profileAccount));
args.putString("filter", filter.toString());
if(!load)
args.putBoolean("noAutoLoad", true);
args.putBoolean("__is_tab", true);
@@ -52,21 +46,20 @@ public class AccountTimelineFragment extends StatusListFragment{
@Override
public void onAttach(Activity activity){
user=Parcels.unwrap(getArguments().getParcelable("profileAccount"));
filter=GetAccountStatuses.Filter.DEFAULT;
filter=GetAccountStatuses.Filter.valueOf(getArguments().getString("filter"));
super.onAttach(activity);
}
@Override
protected void doLoadData(int offset, int count){
currentRequest=new GetAccountStatuses(user.id, offset>0 ? getMaxID() : null, null, count, filter)
currentRequest=new GetAccountStatuses(user.id, getMaxID(), null, count, filter)
.setCallback(new SimpleCallback<>(this){
@Override
public void onSuccess(List<Status> result){
if(getActivity()==null)
return;
boolean empty=result.isEmpty();
AccountSessionManager.get(accountID).filterStatuses(result, FilterContext.ACCOUNT);
onDataLoaded(result, !empty);
if(getActivity()==null) return;
boolean more=applyMaxID(result);
AccountSessionManager.get(accountID).filterStatuses(result, getFilterContext(), user);
onDataLoaded(result, more);
}
})
.exec(accountID);
@@ -86,17 +79,40 @@ public class AccountTimelineFragment extends StatusListFragment{
}
protected void onStatusCreated(Status status){
if(!AccountSessionManager.getInstance().isSelf(accountID, status.account))
AccountSessionManager asm = AccountSessionManager.getInstance();
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(status.inReplyToAccountId!=null && !status.inReplyToAccountId.equals(AccountSessionManager.getInstance().getAccount(accountID).self.id))
return;
}else if(filter==GetAccountStatuses.Filter.MEDIA){
if(status.mediaAttachments.isEmpty())
if(Optional.ofNullable(status.mediaAttachments).map(List::isEmpty).orElse(true))
return;
}
prependItems(Collections.singletonList(status), true);
if (isOnTop()) scrollToTop();
}
protected void onStatusUnpinned(StatusUnpinnedEvent ev){
if(!ev.accountID.equals(accountID) || filter!=GetAccountStatuses.Filter.PINNED)
return;
Status status=getStatusByID(ev.id);
data.remove(status);
preloadedData.remove(status);
HeaderStatusDisplayItem item=findItemOfType(ev.id, HeaderStatusDisplayItem.class);
if(item==null)
return;
int index=displayItems.indexOf(item);
int lastIndex;
for(lastIndex=index;lastIndex<displayItems.size();lastIndex++){
if(!displayItems.get(lastIndex).parentID.equals(ev.id))
break;
}
displayItems.subList(index, lastIndex).clear();
adapter.notifyItemRangeRemoved(index, lastIndex-index);
}
@Override
@@ -104,76 +120,18 @@ public class AccountTimelineFragment extends StatusListFragment{
// no-op
}
@Override
protected RecyclerView.Adapter getAdapter(){
filtersBar=new HorizontalScrollView(getActivity());
LinearLayout filtersLayout=new LinearLayout(getActivity());
filtersBar.addView(filtersLayout);
filtersLayout.setOrientation(LinearLayout.HORIZONTAL);
filtersLayout.setPadding(V.dp(16), V.dp(16), V.dp(16), V.dp(8));
filtersLayout.setDividerDrawable(new EmptyDrawable(V.dp(8), 1));
filtersLayout.setShowDividers(LinearLayout.SHOW_DIVIDER_MIDDLE);
defaultFilter=new FilterChipView(getActivity());
defaultFilter.setText(R.string.posts);
defaultFilter.setTag(GetAccountStatuses.Filter.DEFAULT);
defaultFilter.setSelected(filter==GetAccountStatuses.Filter.DEFAULT);
defaultFilter.setOnClickListener(this::onFilterClick);
filtersLayout.addView(defaultFilter);
withRepliesFilter=new FilterChipView(getActivity());
withRepliesFilter.setText(R.string.posts_and_replies);
withRepliesFilter.setTag(GetAccountStatuses.Filter.INCLUDE_REPLIES);
withRepliesFilter.setSelected(filter==GetAccountStatuses.Filter.INCLUDE_REPLIES);
withRepliesFilter.setOnClickListener(this::onFilterClick);
filtersLayout.addView(withRepliesFilter);
mediaFilter=new FilterChipView(getActivity());
mediaFilter.setText(R.string.media);
mediaFilter.setTag(GetAccountStatuses.Filter.MEDIA);
mediaFilter.setSelected(filter==GetAccountStatuses.Filter.MEDIA);
mediaFilter.setOnClickListener(this::onFilterClick);
filtersLayout.addView(mediaFilter);
MergeRecyclerAdapter mergeAdapter=new MergeRecyclerAdapter();
mergeAdapter.addAdapter(new SingleViewRecyclerAdapter(filtersBar));
mergeAdapter.addAdapter(super.getAdapter());
return mergeAdapter;
protected FilterContext getFilterContext() {
return FilterContext.ACCOUNT;
}
@Override
protected int getMainAdapterOffset(){
return super.getMainAdapterOffset()+1;
}
private FilterChipView getViewForFilter(GetAccountStatuses.Filter filter){
return switch(filter){
case DEFAULT -> defaultFilter;
case INCLUDE_REPLIES -> withRepliesFilter;
case MEDIA -> mediaFilter;
default -> throw new IllegalStateException("Unexpected value: "+filter);
};
}
private void onFilterClick(View v){
GetAccountStatuses.Filter newFilter=(GetAccountStatuses.Filter) v.getTag();
if(newFilter==filter)
return;
// TODO maybe cache the filtered timelines that were already loaded?
if(currentRequest!=null){
currentRequest.cancel();
currentRequest=null;
}
getViewForFilter(filter).setSelected(false);
filter=newFilter;
v.setSelected(true);
data.clear();
preloadedData.clear();
int size=displayItems.size();
displayItems.clear();
adapter.notifyItemRangeRemoved(0, size);
loaded=false;
dataLoading=true;
doLoadData();
public Uri getWebUri(Uri.Builder base) {
// could return different uris based on filter (e.g. media -> "/media"), but i want to
// return the remote url to the user, and i don't know whether i'd need to append
// '#media' (akkoma/pleroma) or '/media' (glitch/mastodon) since i don't know anything
// about the remote instance. so, just returning the base url to the user instead
return Uri.parse(user.url);
}
}

View File

@@ -0,0 +1,119 @@
package org.joinmastodon.android.fragments;
import static java.util.stream.Collectors.toList;
import android.app.Activity;
import android.net.Uri;
import android.os.Bundle;
import android.text.TextUtils;
import android.view.View;
import android.widget.ImageButton;
import com.squareup.otto.Subscribe;
import org.joinmastodon.android.E;
import org.joinmastodon.android.R;
import org.joinmastodon.android.api.requests.announcements.GetAnnouncements;
import org.joinmastodon.android.api.requests.statuses.CreateStatus;
import org.joinmastodon.android.api.requests.statuses.GetScheduledStatuses;
import org.joinmastodon.android.api.session.AccountSession;
import org.joinmastodon.android.api.session.AccountSessionManager;
import org.joinmastodon.android.events.ScheduledStatusCreatedEvent;
import org.joinmastodon.android.events.ScheduledStatusDeletedEvent;
import org.joinmastodon.android.model.Account;
import org.joinmastodon.android.model.Announcement;
import org.joinmastodon.android.model.HeaderPaginationList;
import org.joinmastodon.android.model.Instance;
import org.joinmastodon.android.model.ScheduledStatus;
import org.joinmastodon.android.model.Status;
import org.joinmastodon.android.ui.displayitems.DummyStatusDisplayItem;
import org.joinmastodon.android.ui.displayitems.EmojiReactionsStatusDisplayItem;
import org.joinmastodon.android.ui.displayitems.HeaderStatusDisplayItem;
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.UiUtils;
import org.parceler.Parcels;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import me.grishka.appkit.Nav;
import me.grishka.appkit.api.PaginatedList;
import me.grishka.appkit.api.SimpleCallback;
public class AnnouncementsFragment extends BaseStatusListFragment<Announcement> {
private Instance instance;
private AccountSession session;
private List<String> unreadIDs = null;
@Override
public void onAttach(Activity activity){
super.onAttach(activity);
setTitle(R.string.sk_announcements);
session = AccountSessionManager.getInstance().getAccount(accountID);
instance = AccountSessionManager.getInstance().getInstanceInfo(session.domain);
loadData();
}
@Override
protected List<StatusDisplayItem> buildDisplayItems(Announcement a) {
if(TextUtils.isEmpty(a.content)) return List.of();
Account instanceUser = new Account();
instanceUser.id = instanceUser.acct = instanceUser.username = session.domain;
instanceUser.displayName = instance.title;
instanceUser.url = "https://"+session.domain+"/about";
instanceUser.avatar = instanceUser.avatarStatic = instance.thumbnail;
instanceUser.emojis = List.of();
Status fakeStatus = a.toStatus();
TextStatusDisplayItem textItem = new TextStatusDisplayItem(a.id, HtmlParser.parse(a.content, a.emojis, a.mentions, a.tags, accountID), this, fakeStatus, true);
textItem.textSelectable = true;
List<StatusDisplayItem> items=new ArrayList<>();
items.add(HeaderStatusDisplayItem.fromAnnouncement(a, fakeStatus, instanceUser, this, accountID, this::onMarkAsRead));
items.add(textItem);
if(!isInstanceAkkoma()) items.add(new EmojiReactionsStatusDisplayItem(a.id, this, fakeStatus, accountID, false, true));
return items;
}
public void onMarkAsRead(String id) {
if (unreadIDs == null) return;
unreadIDs.remove(id);
if (unreadIDs.isEmpty()) setResult(true, null);
}
@Override
protected void addAccountToKnown(Announcement s) {}
@Override
public void onItemClick(String id) {}
@Override
protected void doLoadData(int offset, int count){
currentRequest=new GetAnnouncements(true)
.setCallback(new SimpleCallback<>(this){
@Override
public void onSuccess(List<Announcement> result){
if(getActivity()==null) return;
// get unread items first
List<Announcement> data = result.stream().filter(a -> !a.read).collect(toList());
if (data.isEmpty()) setResult(true, null);
else unreadIDs = data.stream().map(a -> a.id).collect(toList());
// append read items at the end
data.addAll(result.stream().filter(a -> a.read).collect(toList()));
onDataLoaded(data, false);
}
})
.exec(accountID);
}
@Override
public Uri getWebUri(Uri.Builder base) {
return isInstanceAkkoma() ? base.path("/announcements").build() : null;
}
}

View File

@@ -1,6 +1,7 @@
package org.joinmastodon.android.fragments;
import android.app.Activity;
import android.app.assist.AssistContent;
import android.content.res.Configuration;
import android.graphics.Canvas;
import android.graphics.Paint;
@@ -11,17 +12,28 @@ import android.os.Bundle;
import android.view.View;
import android.view.ViewGroup;
import android.view.WindowInsets;
import android.view.animation.TranslateAnimation;
import android.widget.ImageButton;
import android.widget.Toolbar;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.recyclerview.widget.RecyclerView;
import org.joinmastodon.android.E;
import org.joinmastodon.android.GlobalUserPreferences;
import org.joinmastodon.android.R;
import org.joinmastodon.android.api.MastodonAPIRequest;
import org.joinmastodon.android.api.requests.accounts.GetAccountRelationships;
import org.joinmastodon.android.api.requests.polls.SubmitPollVote;
import org.joinmastodon.android.api.requests.statuses.AkkomaTranslateStatus;
import org.joinmastodon.android.api.requests.statuses.TranslateStatus;
import org.joinmastodon.android.api.session.AccountSessionManager;
import org.joinmastodon.android.api.session.AccountSessionManager;
import org.joinmastodon.android.api.session.AccountSessionManager;
import org.joinmastodon.android.events.PollUpdatedEvent;
import org.joinmastodon.android.model.Account;
import org.joinmastodon.android.model.AkkomaTranslation;
import org.joinmastodon.android.model.DisplayItemsParent;
import org.joinmastodon.android.model.Poll;
import org.joinmastodon.android.model.Relationship;
@@ -29,21 +41,29 @@ import org.joinmastodon.android.model.Status;
import org.joinmastodon.android.model.Translation;
import org.joinmastodon.android.ui.BetterItemAnimator;
import org.joinmastodon.android.ui.M3AlertDialogBuilder;
import org.joinmastodon.android.ui.sheets.NonMutualPreReplySheet;
import org.joinmastodon.android.ui.sheets.OldPostPreReplySheet;
import org.joinmastodon.android.ui.NonMutualPreReplySheet;
import org.joinmastodon.android.ui.OldPostPreReplySheet;
import org.joinmastodon.android.ui.displayitems.AccountStatusDisplayItem;
import org.joinmastodon.android.ui.displayitems.ExtendedFooterStatusDisplayItem;
import org.joinmastodon.android.ui.displayitems.FooterStatusDisplayItem;
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.PreviewlessMediaGridStatusDisplayItem;
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;
import org.joinmastodon.android.ui.photoviewer.PhotoViewer;
import org.joinmastodon.android.ui.photoviewer.PhotoViewerHost;
import org.joinmastodon.android.ui.utils.InsetStatusItemDecoration;
import org.joinmastodon.android.ui.utils.MediaAttachmentViewController;
import org.joinmastodon.android.ui.utils.PreviewlessMediaAttachmentViewController;
import org.joinmastodon.android.ui.utils.UiUtils;
import org.joinmastodon.android.utils.ProvidesAssistContent;
import org.joinmastodon.android.utils.TypedObjectPool;
import java.time.Instant;
@@ -55,13 +75,17 @@ import java.util.List;
import java.util.Locale;
import java.util.Objects;
import java.util.Set;
import java.util.function.Consumer;
import java.util.stream.Collectors;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.recyclerview.widget.RecyclerView;
import me.grishka.appkit.Nav;
import me.grishka.appkit.api.Callback;
import me.grishka.appkit.api.ErrorResponse;
import me.grishka.appkit.fragments.BaseRecyclerFragment;
import me.grishka.appkit.imageloader.ImageLoaderRecyclerAdapter;
import me.grishka.appkit.imageloader.ImageLoaderViewHolder;
import me.grishka.appkit.imageloader.requests.ImageLoaderRequest;
@@ -70,23 +94,38 @@ import me.grishka.appkit.utils.MergeRecyclerAdapter;
import me.grishka.appkit.utils.V;
import me.grishka.appkit.views.UsableRecyclerView;
public abstract class BaseStatusListFragment<T extends DisplayItemsParent> extends MastodonRecyclerFragment<T> implements PhotoViewerHost, ScrollableToTop{
public abstract class BaseStatusListFragment<T extends DisplayItemsParent> extends MastodonRecyclerFragment<T> implements PhotoViewerHost, ScrollableToTop, IsOnTop, HasFab, ProvidesAssistContent.ProvidesWebUri {
protected ArrayList<StatusDisplayItem> displayItems=new ArrayList<>();
protected DisplayItemsAdapter adapter;
protected String accountID;
protected PhotoViewer currentPhotoViewer;
protected ImageButton fab;
protected int scrollDiff = 0;
protected HashMap<String, Account> knownAccounts=new HashMap<>();
protected HashMap<String, Relationship> relationships=new HashMap<>();
protected Rect tmpRect=new Rect();
protected TypedObjectPool<MediaGridStatusDisplayItem.GridItemType, MediaAttachmentViewController> attachmentViewsPool=new TypedObjectPool<>(this::makeNewMediaAttachmentView);
protected TypedObjectPool<MediaGridStatusDisplayItem.GridItemType, PreviewlessMediaAttachmentViewController> previewlessAttachmentViewsPool=new TypedObjectPool<>(this::makeNewPreviewlessMediaAttachmentView);
protected boolean currentlyScrolling;
protected String maxID;
public BaseStatusListFragment(){
super(20);
if (wantsComposeButton()) setListLayoutId(R.layout.recycler_fragment_with_fab);
}
protected boolean wantsComposeButton() {
return false;
}
@Override
public void onCreate(Bundle savedInstanceState){
super.onCreate(savedInstanceState);
if(GlobalUserPreferences.toolbarMarquee){
setTitleMarqueeEnabled(false);
setSubtitleMarqueeEnabled(false);
}
if(Build.VERSION.SDK_INT>=Build.VERSION_CODES.N)
setRetainInstance(true);
}
@@ -120,7 +159,7 @@ public abstract class BaseStatusListFragment<T extends DisplayItemsParent> exten
displayItems.clear();
}
protected void prependItems(List<T> items, boolean notify){
protected int prependItems(List<T> items, boolean notify){
data.addAll(0, items);
int offset=0;
for(T s:items){
@@ -134,9 +173,12 @@ public abstract class BaseStatusListFragment<T extends DisplayItemsParent> exten
if(notify)
adapter.notifyItemRangeInserted(0, offset);
loadRelationships(items.stream().map(DisplayItemsParent::getAccountID).filter(Objects::nonNull).collect(Collectors.toSet()));
return offset;
}
protected String getMaxID(){
if(refreshing) return null;
if(maxID!=null) return maxID;
if(!preloadedData.isEmpty())
return preloadedData.get(preloadedData.size()-1).getID();
else if(!data.isEmpty())
@@ -145,6 +187,12 @@ public abstract class BaseStatusListFragment<T extends DisplayItemsParent> exten
return null;
}
protected boolean applyMaxID(List<Status> result){
boolean empty=result.isEmpty();
if(!empty) maxID=result.get(result.size()-1).id;
return !empty;
}
protected abstract List<StatusDisplayItem> buildDisplayItems(T s);
protected abstract void addAccountToKnown(T s);
@@ -194,7 +242,7 @@ public abstract class BaseStatusListFragment<T extends DisplayItemsParent> exten
@Override
public boolean startPhotoViewTransition(int index, @NonNull Rect outRect, @NonNull int[] outCornerRadius){
MediaAttachmentViewController holder=findPhotoViewHolder(index);
if(holder!=null){
if(holder!=null && list!=null){
transitioningHolder=holder;
View view=transitioningHolder.photo;
int[] pos={0, 0};
@@ -262,21 +310,159 @@ public abstract class BaseStatusListFragment<T extends DisplayItemsParent> exten
gridHolder.itemView.setHasTransientState(true);
}
public void openPreviewlessMediaPhotoViewer(String parentID, Status _status, int attachmentIndex, PreviewlessMediaGridStatusDisplayItem.Holder gridHolder){
final Status status=_status.getContentStatus();
currentPhotoViewer=new PhotoViewer(getActivity(), status.mediaAttachments, attachmentIndex, status, accountID, new PhotoViewer.Listener(){
private PreviewlessMediaAttachmentViewController transitioningHolder;
@Override
public void setPhotoViewVisibility(int index, boolean visible){
}
@Override
public boolean startPhotoViewTransition(int index, @NonNull Rect outRect, @NonNull int[] outCornerRadius){
PreviewlessMediaAttachmentViewController holder=findPhotoViewHolder(index);
if(holder!=null && list!=null){
transitioningHolder=holder;
View view=transitioningHolder.inner;
int[] pos={0, 0};
view.getLocationOnScreen(pos);
outRect.set(pos[0], pos[1], pos[0]+view.getWidth(), pos[1]+view.getHeight());
list.setClipChildren(false);
gridHolder.setClipChildren(false);
transitioningHolder.view.setElevation(1f);
return true;
}
return false;
}
@Override
public void setTransitioningViewTransform(float translateX, float translateY, float scale){
View view=transitioningHolder.inner;
view.setTranslationX(translateX);
view.setTranslationY(translateY);
view.setScaleX(scale);
view.setScaleY(scale);
}
@Override
public void endPhotoViewTransition(){
View view=transitioningHolder.inner;
view.setTranslationX(0f);
view.setTranslationY(0f);
view.setScaleX(1f);
view.setScaleY(1f);
transitioningHolder.view.setElevation(0f);
if(list!=null)
list.setClipChildren(true);
gridHolder.setClipChildren(true);
transitioningHolder=null;
}
@Nullable
@Override
public Drawable getPhotoViewCurrentDrawable(int index){
return null;
}
@Override
public void photoViewerDismissed(){
currentPhotoViewer=null;
}
@Override
public void onRequestPermissions(String[] permissions){
requestPermissions(permissions, PhotoViewer.PERMISSION_REQUEST);
}
private PreviewlessMediaAttachmentViewController findPhotoViewHolder(int index){
return gridHolder.getViewController(index);
}
});
}
@Override
public @Nullable View getFab() {
if (getParentFragment() instanceof HasFab l) return l.getFab();
else return fab;
}
@Override
public void showFab() {
View fab = getFab();
if (fab == null || fab.getVisibility() == View.VISIBLE) return;
fab.setVisibility(View.VISIBLE);
TranslateAnimation animate = new TranslateAnimation(
0,
0,
fab.getHeight() * 2,
0);
animate.setDuration(300);
fab.startAnimation(animate);
}
public boolean isScrolling() {
return currentlyScrolling;
}
@Override
public void hideFab() {
View fab = getFab();
if (fab == null || fab.getVisibility() != View.VISIBLE) return;
TranslateAnimation animate = new TranslateAnimation(
0,
0,
0,
fab.getHeight() * 2);
animate.setDuration(300);
fab.startAnimation(animate);
fab.setVisibility(View.INVISIBLE);
scrollDiff = 0;
}
@Override
public void onViewCreated(View view, Bundle savedInstanceState){
super.onViewCreated(view, savedInstanceState);
fab=view.findViewById(R.id.fab);
list.addOnScrollListener(new RecyclerView.OnScrollListener(){
@Override
public void onScrolled(@NonNull RecyclerView recyclerView, int dx, int dy){
if(currentPhotoViewer!=null)
currentPhotoViewer.offsetView(-dx, -dy);
View fab = getFab();
if (fab!=null && GlobalUserPreferences.autoHideFab && dy != UiUtils.SCROLL_TO_TOP_DELTA) {
if (dy > 0 && fab.getVisibility() == View.VISIBLE) {
hideFab();
} else if (dy < 0 && fab.getVisibility() != View.VISIBLE) {
if (list.getChildAt(0).getTop() == 0 || scrollDiff > 400) {
showFab();
scrollDiff = 0;
} else {
scrollDiff += Math.abs(dy);
}
}
}
}
@Override
public void onScrollStateChanged(@NonNull RecyclerView recyclerView, int newState) {
super.onScrollStateChanged(recyclerView, newState);
currentlyScrolling = newState != RecyclerView.SCROLL_STATE_IDLE;
}
});
list.addItemDecoration(new StatusListItemDecoration());
list.addItemDecoration(new InsetStatusItemDecoration(this));
((UsableRecyclerView)list).setSelectorBoundsProvider(new UsableRecyclerView.SelectorBoundsProvider(){
private Rect tmpRect=new Rect();
@Override
public void getSelectorBounds(View view, Rect outRect){
if(list!=view.getParent()) return;
boolean hasDescendant=false, hasAncestor=false, isWarning=false;
int lastIndex=-1, firstIndex=-1;
if(((UsableRecyclerView) list).isIncludeMarginsInItemHitbox()){
list.getDecoratedBoundsWithMargins(view, outRect);
}else{
@@ -292,23 +478,55 @@ public abstract class BaseStatusListFragment<T extends DisplayItemsParent> exten
for(int i=0;i<list.getChildCount();i++){
View child=list.getChildAt(i);
holder=list.getChildViewHolder(child);
if(holder instanceof StatusDisplayItem.Holder){
if(holder instanceof StatusDisplayItem.Holder<?> h){
String otherID=((StatusDisplayItem.Holder<?>) holder).getItemID();
if(otherID.equals(id)){
if (firstIndex < 0) firstIndex = i;
lastIndex = i;
StatusDisplayItem item = h.getItem();
hasDescendant = item.hasDescendantNeighbor;
// no for direct descendants because main status (right above) is
// being displayed with an extended footer - no connected layout
hasAncestor = item.hasAncestoringNeighbor && !item.isDirectDescendant;
list.getDecoratedBoundsWithMargins(child, tmpRect);
outRect.left=Math.min(outRect.left, tmpRect.left);
outRect.top=Math.min(outRect.top, tmpRect.top);
outRect.right=Math.max(outRect.right, tmpRect.right);
outRect.bottom=Math.max(outRect.bottom, tmpRect.bottom);
if (holder instanceof WarningFilteredStatusDisplayItem.Holder) {
isWarning = true;
}
}
}
}
}
// shifting the selection box down
// see also: FooterStatusDisplayItem#onBind (setMargins)
if (isWarning || firstIndex < 0 || lastIndex < 0 ||
!(list.getChildViewHolder(list.getChildAt(lastIndex))
instanceof FooterStatusDisplayItem.Holder)) return;
int prevIndex = firstIndex - 1, nextIndex = lastIndex + 1;
boolean prevIsWarning = prevIndex > 0 && prevIndex < list.getChildCount() &&
list.getChildViewHolder(list.getChildAt(prevIndex))
instanceof WarningFilteredStatusDisplayItem.Holder;
boolean nextIsWarning = nextIndex > 0 && nextIndex < list.getChildCount() &&
list.getChildViewHolder(list.getChildAt(nextIndex))
instanceof WarningFilteredStatusDisplayItem.Holder;
if (!prevIsWarning && hasAncestor) outRect.top += V.dp(4);
if (!nextIsWarning && hasDescendant) outRect.bottom += V.dp(4);
}
});
list.setItemAnimator(new BetterItemAnimator());
((UsableRecyclerView) list).setIncludeMarginsInItemHitbox(true);
updateToolbar();
if (wantsComposeButton() && !getArguments().getBoolean("__disable_fab", false)) {
fab.setVisibility(View.VISIBLE);
fab.setOnClickListener(this::onFabClick);
fab.setOnLongClickListener(this::onFabLongClick);
} else if (fab != null) {
fab.setVisibility(View.GONE);
}
}
@Override
@@ -349,10 +567,14 @@ public abstract class BaseStatusListFragment<T extends DisplayItemsParent> exten
protected void updatePoll(String itemID, Status status, Poll poll){
status.poll=poll;
int firstOptionIndex=-1, footerIndex=-1;
int spoilerFirstOptionIndex=-1, spoilerFooterIndex=-1;
SpoilerStatusDisplayItem spoilerItem=null;
int i=0;
for(StatusDisplayItem item:displayItems){
if(item.parentID.equals(itemID)){
if(item instanceof PollOptionStatusDisplayItem && firstOptionIndex==-1){
if(item instanceof SpoilerStatusDisplayItem){
spoilerItem=(SpoilerStatusDisplayItem) item;
}else if(item instanceof PollOptionStatusDisplayItem && firstOptionIndex==-1){
firstOptionIndex=i;
}else if(item instanceof PollFooterStatusDisplayItem){
footerIndex=i;
@@ -361,12 +583,39 @@ public abstract class BaseStatusListFragment<T extends DisplayItemsParent> exten
}
i++;
}
// This is a temporary measure to deal with the app crashing when the poll isn't updated.
// This is needed because of a possible id mismatch that screws with things
if(firstOptionIndex==-1 || footerIndex==-1){
for(StatusDisplayItem item:displayItems){
if(status.id.equals(itemID)){
if(item instanceof SpoilerStatusDisplayItem){
spoilerItem=(SpoilerStatusDisplayItem) item;
}else if(item instanceof PollOptionStatusDisplayItem && firstOptionIndex==-1){
firstOptionIndex=i;
}else if(item instanceof PollFooterStatusDisplayItem){
footerIndex=i;
break;
}
}
i++;
}
}
if(firstOptionIndex==-1 || footerIndex==-1)
throw new IllegalStateException("Can't find all poll items in displayItems");
List<StatusDisplayItem> pollItems=displayItems.subList(firstOptionIndex, footerIndex+1);
int prevSize=pollItems.size();
if(spoilerItem!=null){
spoilerFirstOptionIndex=spoilerItem.contentItems.indexOf(pollItems.get(0));
spoilerFooterIndex=spoilerItem.contentItems.indexOf(pollItems.get(pollItems.size()-1));
}
pollItems.clear();
StatusDisplayItem.buildPollItems(itemID, this, poll, status, pollItems);
if(spoilerItem!=null){
spoilerItem.contentItems.subList(spoilerFirstOptionIndex, spoilerFooterIndex+1).clear();
spoilerItem.contentItems.addAll(spoilerFirstOptionIndex, pollItems);
}
if(prevSize!=pollItems.size()){
adapter.notifyItemRangeRemoved(firstOptionIndex, prevSize);
adapter.notifyItemRangeInserted(firstOptionIndex, pollItems.size());
@@ -378,10 +627,13 @@ public abstract class BaseStatusListFragment<T extends DisplayItemsParent> exten
public void onPollOptionClick(PollOptionStatusDisplayItem.Holder holder){
Poll poll=holder.getItem().poll;
Poll.Option option=holder.getItem().option;
if(poll.multiple){
// MEGALODON: always show vote button
// if(poll.multiple){
if(poll.selectedOptions==null)
poll.selectedOptions=new ArrayList<>();
if(poll.selectedOptions.contains(option)){
boolean optionContained=poll.selectedOptions.contains(option);
if(!poll.multiple) poll.selectedOptions.clear();
if(optionContained){
poll.selectedOptions.remove(option);
holder.itemView.setSelected(false);
}else{
@@ -390,6 +642,9 @@ public abstract class BaseStatusListFragment<T extends DisplayItemsParent> exten
}
for(int i=0;i<list.getChildCount();i++){
RecyclerView.ViewHolder vh=list.getChildViewHolder(list.getChildAt(i));
if(!poll.multiple && vh instanceof PollOptionStatusDisplayItem.Holder item){
if(item!=holder) item.itemView.setSelected(false);
}
if(vh instanceof PollFooterStatusDisplayItem.Holder footer){
if(footer.getItemID().equals(holder.getItemID())){
footer.rebind();
@@ -397,9 +652,9 @@ public abstract class BaseStatusListFragment<T extends DisplayItemsParent> exten
}
}
}
}else{
submitPollVote(holder.getItemID(), poll.id, Collections.singletonList(poll.options.indexOf(option)));
}
// }else{
// submitPollVote(holder.getItemID(), poll.id, Collections.singletonList(poll.options.indexOf(option)));
// }
}
public void onPollVoteButtonClick(PollFooterStatusDisplayItem.Holder holder){
@@ -407,6 +662,14 @@ public abstract class BaseStatusListFragment<T extends DisplayItemsParent> exten
submitPollVote(holder.getItemID(), poll.id, poll.selectedOptions.stream().map(opt->poll.options.indexOf(opt)).collect(Collectors.toList()));
}
public void onPollViewResultsButtonClick(PollFooterStatusDisplayItem.Holder holder, boolean shown){
for(int i=0;i<list.getChildCount();i++){
if(list.getChildViewHolder(list.getChildAt(i)) instanceof PollOptionStatusDisplayItem.Holder item && item.getItemID().equals(holder.getItemID())){
item.showResults(shown);
}
}
}
protected void submitPollVote(String parentID, String pollID, List<Integer> choices){
if(refreshing)
return;
@@ -428,15 +691,39 @@ public abstract class BaseStatusListFragment<T extends DisplayItemsParent> exten
public void onRevealSpoilerClick(SpoilerStatusDisplayItem.Holder holder){
Status status=holder.getItem().status;
toggleSpoiler(status, holder.getItemID());
boolean isForQuote=holder.getItem().isForQuote;
toggleSpoiler(status, isForQuote, holder.getItemID());
}
protected void toggleSpoiler(Status status, String itemID){
public void onVisibilityIconClick(HeaderStatusDisplayItem.Holder holder) {
Status status = holder.getItem().status;
if(holder.getItem().hasVisibilityToggle) holder.animateVisibilityToggle(false);
MediaGridStatusDisplayItem.Holder mediaGrid=findHolderOfType(holder.getItemID(), MediaGridStatusDisplayItem.Holder.class);
if(mediaGrid!=null){
if(!status.sensitiveRevealed) mediaGrid.revealSensitive();
else mediaGrid.hideSensitive();
}else{
status.sensitiveRevealed=false;
notifyItemChangedAfter(holder.getItem(), MediaGridStatusDisplayItem.class);
}
}
public void onSensitiveRevealed(MediaGridStatusDisplayItem.Holder holder) {
HeaderStatusDisplayItem.Holder header=findHolderOfType(holder.getItemID(), HeaderStatusDisplayItem.Holder.class);
if(header!=null && header.getItem().hasVisibilityToggle) header.animateVisibilityToggle(true);
else notifyItemChangedBefore(holder.getItem(), HeaderStatusDisplayItem.class);
}
protected void toggleSpoiler(Status status, boolean isForQuote, String itemID){
status.spoilerRevealed=!status.spoilerRevealed;
SpoilerStatusDisplayItem.Holder spoiler=findHolderOfType(itemID, SpoilerStatusDisplayItem.Holder.class);
if(spoiler!=null)
spoiler.rebind();
SpoilerStatusDisplayItem spoilerItem=Objects.requireNonNull(findItemOfType(itemID, SpoilerStatusDisplayItem.class));
if (!status.spoilerRevealed && !AccountSessionManager.get(accountID).getLocalPreferences().revealCWs)
status.sensitiveRevealed = false;
List<SpoilerStatusDisplayItem.Holder> spoilers=findAllHoldersOfType(itemID, SpoilerStatusDisplayItem.Holder.class);
SpoilerStatusDisplayItem.Holder spoiler=spoilers.size() > 1 && isForQuote ? spoilers.get(1) : spoilers.get(0);
if(spoiler!=null) spoiler.rebind();
else notifyItemChanged(itemID, SpoilerStatusDisplayItem.class);
SpoilerStatusDisplayItem spoilerItem=Objects.requireNonNull(spoiler.getItem());
int index=displayItems.indexOf(spoilerItem);
if(status.spoilerRevealed){
@@ -446,11 +733,44 @@ public abstract class BaseStatusListFragment<T extends DisplayItemsParent> exten
displayItems.subList(index+1, index+1+spoilerItem.contentItems.size()).clear();
adapter.notifyItemRangeRemoved(index+1, spoilerItem.contentItems.size());
}
notifyItemChanged(itemID, TextStatusDisplayItem.class);
HeaderStatusDisplayItem.Holder header=findHolderOfType(itemID, HeaderStatusDisplayItem.Holder.class);
if(header!=null) header.rebind();
else notifyItemChanged(itemID, HeaderStatusDisplayItem.class);
list.invalidateItemDecorations();
}
public void onGapClick(GapStatusDisplayItem.Holder item){}
public void onEnableExpandable(TextStatusDisplayItem.Holder holder, boolean expandable) {
Status s=holder.getItem().status;
if(s.textExpandable!=expandable && list!=null) {
s.textExpandable=expandable;
HeaderStatusDisplayItem.Holder header=findHolderOfType(holder.getItemID(), HeaderStatusDisplayItem.Holder.class);
if(header!=null) header.bindCollapseButton();
}
}
public void onToggleExpanded(Status status, String itemID) {
status.textExpanded = !status.textExpanded;
notifyItemChanged(itemID, TextStatusDisplayItem.class);
HeaderStatusDisplayItem.Holder header=findHolderOfType(itemID, HeaderStatusDisplayItem.Holder.class);
if(header!=null) header.animateExpandToggle();
else notifyItemChanged(itemID, HeaderStatusDisplayItem.class);
}
public void onGapClick(GapStatusDisplayItem.Holder item, boolean downwards){}
public void onWarningClick(WarningFilteredStatusDisplayItem.Holder warning){
int startPos = warning.getAbsoluteAdapterPosition();
displayItems.remove(startPos);
displayItems.addAll(startPos, warning.filteredItems);
adapter.notifyItemRangeInserted(startPos, warning.filteredItems.size() - 1);
if (startPos == 0) scrollToTop();
warning.getItem().status.filterRevealed = true;
}
@Override
public String getAccountID(){
return accountID;
}
@@ -498,9 +818,61 @@ public abstract class BaseStatusListFragment<T extends DisplayItemsParent> exten
return null;
}
/**
* Use this as a fallback if findHolderOfType fails to find the ViewHolder.
* It might still be bound but off-screen and therefore not a child of the RecyclerView -
* resulting in the ViewHolder displaying an outdated state once scrolled back into view.
*/
protected <I extends StatusDisplayItem> int notifyItemChanged(String id, Class<I> type){
boolean encounteredParent=false;
for(int i=0; i<displayItems.size(); i++){
StatusDisplayItem item=displayItems.get(i);
boolean idEquals=id.equals(item.parentID);
if(!encounteredParent && idEquals) encounteredParent=true; // reached top of the parent
else if(encounteredParent && !idEquals) break; // passed by bottom of the parent. man muss ja wissen wann schluss is
if(idEquals && type.isInstance(item)){
adapter.notifyItemChanged(i);
return i;
}
}
return -1;
}
protected <I extends StatusDisplayItem> int notifyItemChangedAfter(StatusDisplayItem afterThis, Class<I> type){
int startIndex=displayItems.indexOf(afterThis);
if(startIndex == -1) throw new IllegalStateException("notifyItemChangedAfter didn't find the passed StatusDisplayItem");
String parentID=afterThis.parentID;
for(int i=startIndex; i<displayItems.size(); i++){
StatusDisplayItem item=displayItems.get(i);
if(!parentID.equals(item.parentID)) break; // didn't find anything
if(type.isInstance(item)){
// found it
adapter.notifyItemChanged(i);
return i;
}
}
return -1;
}
protected <I extends StatusDisplayItem> int notifyItemChangedBefore(StatusDisplayItem beforeThis, Class<I> type){
int startIndex=displayItems.indexOf(beforeThis);
if(startIndex == -1) throw new IllegalStateException("notifyItemChangedBefore didn't find the passed StatusDisplayItem");
String parentID=beforeThis.parentID;
for(int i=startIndex; i>=0; i--){
StatusDisplayItem item=displayItems.get(i);
if(!parentID.equals(item.parentID)) break; // didn't find anything
if(type.isInstance(item)){
// found it
adapter.notifyItemChanged(i);
return i;
}
}
return -1;
}
@Nullable
protected <I extends StatusDisplayItem, H extends StatusDisplayItem.Holder<I>> H findHolderOfType(String id, Class<H> type){
for(int i=0;i<list.getChildCount();i++){
for(int i=0; i<list.getChildCount(); i++){
RecyclerView.ViewHolder holder=list.getChildViewHolder(list.getChildAt(i));
if(holder instanceof StatusDisplayItem.Holder<?> itemHolder && itemHolder.getItemID().equals(id) && type.isInstance(holder))
return type.cast(holder);
@@ -523,6 +895,11 @@ public abstract class BaseStatusListFragment<T extends DisplayItemsParent> exten
smoothScrollRecyclerViewToTop(list);
}
@Override
public boolean isOnTop() {
return isRecyclerViewOnTop(list);
}
protected int getListWidthForMediaLayout(){
return list.getWidth();
}
@@ -569,14 +946,37 @@ public abstract class BaseStatusListFragment<T extends DisplayItemsParent> exten
currentPhotoViewer.onPause();
}
public void onFabClick(View v){
Bundle args=new Bundle();
args.putString("account", accountID);
Nav.go(getActivity(), ComposeFragment.class, args);
}
public boolean onFabLongClick(View v) {
return UiUtils.pickAccountForCompose(getActivity(), accountID);
}
private MediaAttachmentViewController makeNewMediaAttachmentView(MediaGridStatusDisplayItem.GridItemType type){
return new MediaAttachmentViewController(getActivity(), type);
}
private PreviewlessMediaAttachmentViewController makeNewPreviewlessMediaAttachmentView(MediaGridStatusDisplayItem.GridItemType type){
return new PreviewlessMediaAttachmentViewController(getActivity(), type);
}
public TypedObjectPool<MediaGridStatusDisplayItem.GridItemType, MediaAttachmentViewController> getAttachmentViewsPool(){
return attachmentViewsPool;
}
public TypedObjectPool<MediaGridStatusDisplayItem.GridItemType, PreviewlessMediaAttachmentViewController> getPreviewlessAttachmentViewsPool(){
return previewlessAttachmentViewsPool;
}
@Override
public void onProvideAssistContent(AssistContent assistContent) {
assistContent.setWebUri(getWebUri(getSession().getInstanceUri().buildUpon()));
}
public void togglePostTranslation(Status status, String itemID){
switch(status.translationState){
case LOADING -> {
@@ -590,44 +990,64 @@ public abstract class BaseStatusListFragment<T extends DisplayItemsParent> exten
status.translationState=Status.TranslationState.SHOWN;
}else{
status.translationState=Status.TranslationState.LOADING;
new TranslateStatus(status.getContentStatus().id, Locale.getDefault().getLanguage())
.setCallback(new Callback<>(){
Consumer<Translation> successCallback=(result)->{
status.translation=result;
status.translationState=Status.TranslationState.SHOWN;
updateTranslation(itemID);
};
MastodonAPIRequest<?> req=isInstanceAkkoma()
? new AkkomaTranslateStatus(status.getContentStatus().id, Locale.getDefault().getLanguage()).setCallback(new Callback<>(){
@Override
public void onSuccess(AkkomaTranslation result){
if(getActivity()!=null) successCallback.accept(result.toTranslation());
}
@Override
public void onError(ErrorResponse error){
if(getActivity()!=null) translationCallbackError(status, itemID);
}
})
: new TranslateStatus(status.getContentStatus().id, Locale.getDefault().getLanguage()).setCallback(new Callback<>(){
@Override
public void onSuccess(Translation result){
if(getActivity()==null)
return;
status.translation=result;
status.translationState=Status.TranslationState.SHOWN;
updateTranslation(itemID);
if(getActivity()!=null) successCallback.accept(result);
}
@Override
public void onError(ErrorResponse error){
if(getActivity()==null)
return;
status.translationState=Status.TranslationState.HIDDEN;
updateTranslation(itemID);
new M3AlertDialogBuilder(getActivity())
.setTitle(R.string.error)
.setMessage(R.string.translation_failed)
.setPositiveButton(R.string.ok, null)
.show();
if(getActivity()!=null) translationCallbackError(status, itemID);
}
})
.exec(accountID);
});
// 1 minute
req.setTimeout(60000).exec(accountID);
}
}
}
updateTranslation(itemID);
}
private void translationCallbackError(Status status, String itemID) {
status.translationState=Status.TranslationState.HIDDEN;
updateTranslation(itemID);
new M3AlertDialogBuilder(getActivity())
.setTitle(R.string.error)
.setMessage(R.string.translation_failed)
.setPositiveButton(R.string.ok, null)
.show();
}
private void updateTranslation(String itemID) {
TextStatusDisplayItem.Holder text=findHolderOfType(itemID, TextStatusDisplayItem.Holder.class);
if(text!=null){
text.updateTranslation(true);
imgLoader.bindViewHolder((ImageLoaderRecyclerAdapter) list.getAdapter(), text, text.getAbsoluteAdapterPosition());
}else{
notifyItemChanged(itemID, TextStatusDisplayItem.class);
}
if(isInstanceAkkoma())
return;
SpoilerStatusDisplayItem.Holder spoiler=findHolderOfType(itemID, SpoilerStatusDisplayItem.Holder.class);
if(spoiler!=null){
spoiler.rebind();
@@ -638,6 +1058,11 @@ public abstract class BaseStatusListFragment<T extends DisplayItemsParent> exten
media.rebind();
}
PreviewlessMediaGridStatusDisplayItem.Holder previewLessMedia=findHolderOfType(itemID, PreviewlessMediaGridStatusDisplayItem.Holder.class);
if (previewLessMedia!=null) {
previewLessMedia.rebind();
}
for(int i=0;i<list.getChildCount();i++){
if(list.getChildViewHolder(list.getChildAt(i)) instanceof PollOptionStatusDisplayItem.Holder item){
item.rebind();
@@ -675,6 +1100,20 @@ public abstract class BaseStatusListFragment<T extends DisplayItemsParent> exten
protected void onModifyItemViewHolder(BindableViewHolder<StatusDisplayItem> holder){}
@Override
protected void onDataLoaded(List<T> 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 && data.size() < itemsPerPage){
preloader.onScrolledToLastItem();
}
}
public void scrollBy(int x, int y) {
list.scrollBy(x, y);
}
protected class DisplayItemsAdapter extends UsableRecyclerView.Adapter<BindableViewHolder<StatusDisplayItem>> implements ImageLoaderRecyclerAdapter{
public DisplayItemsAdapter(){
@@ -720,9 +1159,9 @@ public abstract class BaseStatusListFragment<T extends DisplayItemsParent> exten
private Paint dividerPaint=new Paint();
{
dividerPaint.setColor(UiUtils.getThemeColor(getActivity(), R.attr.colorM3OutlineVariant));
dividerPaint.setColor(UiUtils.getThemeColor(getActivity(), GlobalUserPreferences.showDividers ? R.attr.colorM3OutlineVariant : R.attr.colorM3Surface));
dividerPaint.setStyle(Paint.Style.STROKE);
dividerPaint.setStrokeWidth(V.dp(0.5f));
dividerPaint.setStrokeWidth(V.dp(1f));
}
@Override
@@ -745,7 +1184,8 @@ public abstract class BaseStatusListFragment<T extends DisplayItemsParent> exten
// 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;
return !ih.getItemID().equals(sh.getItemID()) && ih.getItem().getType()!=StatusDisplayItem.Type.GAP;
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;
}
return false;
}

View File

@@ -1,10 +1,12 @@
package org.joinmastodon.android.fragments;
import android.app.Activity;
import android.net.Uri;
import org.joinmastodon.android.R;
import org.joinmastodon.android.api.requests.statuses.GetBookmarkedStatuses;
import org.joinmastodon.android.events.RemoveAccountPostsEvent;
import org.joinmastodon.android.model.FilterContext;
import org.joinmastodon.android.model.HeaderPaginationList;
import org.joinmastodon.android.model.Status;
@@ -26,6 +28,7 @@ public class BookmarkedStatusListFragment extends StatusListFragment{
.setCallback(new SimpleCallback<>(this){
@Override
public void onSuccess(HeaderPaginationList<Status> result){
if(getActivity()==null) return;
if(result.nextPageUri!=null)
nextMaxID=result.nextPageUri.getQueryParameter("max_id");
else
@@ -40,4 +43,14 @@ public class BookmarkedStatusListFragment extends StatusListFragment{
protected void onRemoveAccountPostsEvent(RemoveAccountPostsEvent ev){
// no-op
}
@Override
protected FilterContext getFilterContext() {
return FilterContext.ACCOUNT;
}
@Override
public Uri getWebUri(Uri.Builder base) {
return base.path("/bookmarks").build();
}
}

View File

@@ -20,13 +20,15 @@ import android.view.inputmethod.InputMethodManager;
import android.widget.EditText;
import android.widget.ImageView;
import org.joinmastodon.android.GlobalUserPreferences;
import org.joinmastodon.android.R;
import org.joinmastodon.android.api.MastodonAPIController;
import org.joinmastodon.android.api.session.AccountSessionManager;
import org.joinmastodon.android.model.Attachment;
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 java.util.Collections;
@@ -42,22 +44,24 @@ public class ComposeImageDescriptionFragment extends MastodonToolbarFragment imp
private String accountID, attachmentID;
private EditText edit;
private FixedAspectRatioImageView image;
private ImageView image;
private ContextThemeWrapper themeWrapper;
private PhotoViewer photoViewer;
@Override
public void onCreate(Bundle savedInstanceState){
super.onCreate(savedInstanceState);
accountID=getArguments().getString("account");
attachmentID=getArguments().getString("attachment");
setHasOptionsMenu(true);
}
@Override
public void onAttach(Activity activity){
super.onAttach(activity);
accountID=getArguments().getString("account");
attachmentID=getArguments().getString("attachment");
themeWrapper=new ContextThemeWrapper(activity, R.style.Theme_Mastodon_Dark);
ColorPalette.palettes.get(AccountSessionManager.get(accountID).getLocalPreferences().getCurrentColor())
.apply(themeWrapper, GlobalUserPreferences.ThemePreference.DARK);
setTitle(R.string.add_alt_text);
}
@@ -75,7 +79,7 @@ public class ComposeImageDescriptionFragment extends MastodonToolbarFragment imp
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.setAspectRatio(Math.max(1f, (float)width/height));
}
image.setOnClickListener(v->openPhotoViewer());
Uri uri=getArguments().getParcelable("uri");

View File

@@ -0,0 +1,97 @@
package org.joinmastodon.android.fragments;
import android.app.Activity;
import android.net.Uri;
import android.view.Menu;
import android.view.MenuInflater;
import org.joinmastodon.android.R;
import org.joinmastodon.android.api.requests.timelines.GetPublicTimeline;
import org.joinmastodon.android.model.Filter;
import org.joinmastodon.android.model.FilterContext;
import org.joinmastodon.android.model.Status;
import org.joinmastodon.android.model.TimelineDefinition;
import org.joinmastodon.android.ui.utils.UiUtils;
import org.joinmastodon.android.utils.ProvidesAssistContent;
import org.joinmastodon.android.utils.StatusFilterPredicate;
import java.util.List;
import java.util.stream.Collectors;
import me.grishka.appkit.api.SimpleCallback;
public class CustomLocalTimelineFragment extends PinnableStatusListFragment implements ProvidesAssistContent.ProvidesWebUri{
// private String name;
private String domain;
private String maxID;
@Override
protected boolean wantsComposeButton() {
return false;
}
@Override
public void onAttach(Activity activity){
super.onAttach(activity);
domain=getArguments().getString("domain");
updateTitle(domain);
setHasOptionsMenu(true);
}
private void updateTitle(String domain) {
this.domain = domain;
setTitle(this.domain);
}
@Override
protected void doLoadData(int offset, int count){
currentRequest=new GetPublicTimeline(true, false, refreshing ? null : maxID, null, count, null, getLocalPrefs().timelineReplyVisibility)
.setCallback(new SimpleCallback<>(this){
@Override
public void onSuccess(List<Status> result){
if(!result.isEmpty())
maxID=result.get(result.size()-1).id;
if (getActivity() == null) return;
result=result.stream().filter(new StatusFilterPredicate(accountID, FilterContext.PUBLIC)).collect(Collectors.toList());
result.stream().forEach(status -> {
status.account.acct += "@"+domain;
status.mentions.forEach(mention -> mention.id = null);
status.isRemote = true;
});
onDataLoaded(result, !result.isEmpty());
}
})
.execNoAuth(domain);
}
@Override
protected void onShown(){
super.onShown();
if(!getArguments().getBoolean("noAutoLoad") && !loaded && !dataLoading)
loadData();
}
@Override
public void onCreateOptionsMenu(Menu menu, MenuInflater inflater) {
inflater.inflate(R.menu.custom_local_timelines, menu);
super.onCreateOptionsMenu(menu, inflater);
UiUtils.enableOptionsMenuIcons(getContext(), menu, R.id.pin);
}
@Override
protected FilterContext getFilterContext() {
return null;
}
@Override
public Uri getWebUri(Uri.Builder base) {
return Uri.parse(domain);
}
@Override
protected TimelineDefinition makeTimelineDefinition() {
return TimelineDefinition.ofCustomLocalTimeline(domain);
}
}

View File

@@ -0,0 +1,516 @@
package org.joinmastodon.android.fragments;
import static android.view.Menu.NONE;
import static com.hootsuite.nachos.terminator.ChipTerminatorHandler.BEHAVIOR_CHIPIFY_ALL;
import static org.joinmastodon.android.ui.utils.UiUtils.makeBackItem;
import android.annotation.SuppressLint;
import android.app.AlertDialog;
import android.content.Context;
import android.os.Bundle;
import android.text.InputType;
import android.text.TextUtils;
import android.view.Menu;
import android.view.MenuInflater;
import android.view.MenuItem;
import android.view.MotionEvent;
import android.view.SubMenu;
import android.view.View;
import android.view.ViewGroup;
import android.widget.Button;
import android.widget.EditText;
import android.widget.FrameLayout;
import android.widget.ImageButton;
import android.widget.ImageView;
import android.widget.LinearLayout;
import android.widget.PopupMenu;
import android.widget.Switch;
import android.widget.TextView;
import android.widget.Toast;
import androidx.annotation.DrawableRes;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.recyclerview.widget.ItemTouchHelper;
import androidx.recyclerview.widget.RecyclerView;
import com.hootsuite.nachos.NachoTextView;
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;
import org.joinmastodon.android.model.Hashtag;
import org.joinmastodon.android.model.HeaderPaginationList;
import org.joinmastodon.android.model.ListTimeline;
import org.joinmastodon.android.model.TimelineDefinition;
import org.joinmastodon.android.ui.DividerItemDecoration;
import org.joinmastodon.android.ui.M3AlertDialogBuilder;
import org.joinmastodon.android.ui.utils.UiUtils;
import org.joinmastodon.android.ui.views.TextInputFrameLayout;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.function.Consumer;
import me.grishka.appkit.api.Callback;
import me.grishka.appkit.api.ErrorResponse;
import me.grishka.appkit.utils.BindableViewHolder;
import me.grishka.appkit.utils.V;
import me.grishka.appkit.views.UsableRecyclerView;
public class EditTimelinesFragment extends MastodonRecyclerFragment<TimelineDefinition> implements ScrollableToTop{
private String accountID;
private TimelinesAdapter adapter;
private final ItemTouchHelper itemTouchHelper;
private Menu optionsMenu;
private boolean updated;
private final Map<MenuItem, TimelineDefinition> timelineByMenuItem=new HashMap<>();
private final List<ListTimeline> listTimelines=new ArrayList<>();
private final List<Hashtag> hashtags=new ArrayList<>();
private MenuItem addHashtagItem;
private final List<CustomLocalTimeline> localTimelines = new ArrayList<>();
public EditTimelinesFragment(){
super(10);
ItemTouchHelper.SimpleCallback itemTouchCallback=new ItemTouchHelperCallback();
itemTouchHelper=new ItemTouchHelper(itemTouchCallback);
}
@Override
public void onCreate(Bundle savedInstanceState){
super.onCreate(savedInstanceState);
setHasOptionsMenu(true);
setTitle(R.string.sk_timelines);
accountID=getArguments().getString("account");
new GetLists().setCallback(new Callback<>(){
@Override
public void onSuccess(List<ListTimeline> result){
listTimelines.addAll(result);
updateOptionsMenu();
}
@Override
public void onError(ErrorResponse error){
error.showToast(getContext());
}
}).exec(accountID);
new GetFollowedHashtags().setCallback(new Callback<>(){
@Override
public void onSuccess(HeaderPaginationList<Hashtag> result){
hashtags.addAll(result);
updateOptionsMenu();
}
@Override
public void onError(ErrorResponse error){
error.showToast(getContext());
}
}).exec(accountID);
}
@Override
protected void onShown(){
super.onShown();
if(!getArguments().getBoolean("noAutoLoad") && !loaded && !dataLoading) loadData();
}
@Override
public void onViewCreated(View view, Bundle savedInstanceState){
super.onViewCreated(view, savedInstanceState);
itemTouchHelper.attachToRecyclerView(list);
refreshLayout.setEnabled(false);
list.addItemDecoration(new DividerItemDecoration(getActivity(), R.attr.colorM3OutlineVariant, 0.5f, 56, 16));
}
@Override
public void onCreateOptionsMenu(Menu menu, MenuInflater inflater){
this.optionsMenu=menu;
updateOptionsMenu();
}
@Override
public boolean onOptionsItemSelected(MenuItem item){
if(item.getItemId()==R.id.menu_back){
updateOptionsMenu();
optionsMenu.performIdentifierAction(R.id.menu_add_timeline, 0);
return true;
}
if (item.getItemId() == R.id.menu_add_local_timelines) {
addNewLocalTimeline();
return true;
}
TimelineDefinition tl = timelineByMenuItem.get(item);
if (tl != null) {
addTimeline(tl);
} else if (item == addHashtagItem) {
makeTimelineEditor(null, (hashtag) -> {
if (hashtag != null) addTimeline(hashtag);
}, null);
}
return true;
}
private void addTimeline(TimelineDefinition tl){
data.add(tl.copy());
adapter.notifyItemInserted(data.size());
saveTimelines();
updateOptionsMenu();
}
private void addNewLocalTimeline() {
FrameLayout inputWrap = new FrameLayout(getContext());
EditText input = new EditText(getContext());
input.setHint(R.string.sk_example_domain);
input.setInputType(InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_URI);
FrameLayout.LayoutParams params = new FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT);
params.setMargins(V.dp(16), V.dp(4), V.dp(16), V.dp(16));
input.setLayoutParams(params);
inputWrap.addView(input);
new M3AlertDialogBuilder(getContext()).setTitle(R.string.mo_add_custom_server_local_timeline).setView(inputWrap)
.setPositiveButton(R.string.save, (d, which) -> {
TimelineDefinition tl = TimelineDefinition.ofCustomLocalTimeline(input.getText().toString().trim());
data.add(tl);
saveTimelines();
})
.setNegativeButton(R.string.cancel, (d, which) -> {
})
.show();
}
private void addTimelineToOptions(TimelineDefinition tl, Menu menu) {
if (data.contains(tl)) return;
MenuItem item = addOptionsItem(menu, tl.getTitle(getContext()), tl.getIcon().iconRes);
timelineByMenuItem.put(item, tl);
}
private MenuItem addOptionsItem(Menu menu, String name, @DrawableRes int icon){
MenuItem item=menu.add(0, View.generateViewId(), Menu.NONE, name);
item.setIcon(icon);
return item;
}
private void updateOptionsMenu(){
if(getActivity()==null) return;
optionsMenu.clear();
timelineByMenuItem.clear();
SubMenu menu=optionsMenu.addSubMenu(0, R.id.menu_add_timeline, NONE, R.string.sk_timelines_add);
menu.getItem().setShowAsAction(MenuItem.SHOW_AS_ACTION_ALWAYS);
menu.getItem().setIcon(R.drawable.ic_fluent_add_24_regular);
SubMenu timelinesMenu=menu.addSubMenu(R.string.sk_timeline);
timelinesMenu.getItem().setIcon(R.drawable.ic_fluent_timeline_24_regular);
SubMenu listsMenu=menu.addSubMenu(R.string.sk_list);
listsMenu.getItem().setIcon(R.drawable.ic_fluent_people_24_regular);
SubMenu hashtagsMenu=menu.addSubMenu(R.string.sk_hashtag);
hashtagsMenu.getItem().setIcon(R.drawable.ic_fluent_number_symbol_24_regular);
MenuItem addLocalTimelines = menu.add(0, R.id.menu_add_local_timelines, NONE, R.string.local_timeline);
addLocalTimelines.setIcon(R.drawable.ic_fluent_add_24_regular);
makeBackItem(timelinesMenu);
makeBackItem(listsMenu);
makeBackItem(hashtagsMenu);
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));
timelinesMenu.getItem().setVisible(timelinesMenu.size()>0);
listsMenu.getItem().setVisible(listsMenu.size()>0);
hashtagsMenu.getItem().setVisible(hashtagsMenu.size()>0);
UiUtils.enableOptionsMenuIcons(getContext(), optionsMenu, R.id.menu_add_timeline);
}
private void saveTimelines(){
updated=true;
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);
adapter.notifyItemRemoved(position);
saveTimelines();
updateOptionsMenu();
}
@Override
protected void doLoadData(int offset, int count){
onDataLoaded(AccountSessionManager.get(accountID).getLocalPreferences().timelines);
updateOptionsMenu();
}
@Override
protected RecyclerView.Adapter<TimelineViewHolder> getAdapter(){
return adapter=new TimelinesAdapter();
}
@Override
public void scrollToTop(){
smoothScrollRecyclerViewToTop(list);
}
@Override
public void onDestroy(){
super.onDestroy();
if(updated) UiUtils.restartApp();
}
private boolean setTagListContent(NachoTextView editText, @Nullable List<String> tags){
if(tags==null || tags.isEmpty()) return false;
editText.setText(tags);
editText.chipifyAllUnterminatedTokens();
return true;
}
private NachoTextView prepareChipTextView(NachoTextView nacho){
//Ill Be Back
nacho.setChipTerminators(
Map.of(
',', BEHAVIOR_CHIPIFY_ALL,
'\n', BEHAVIOR_CHIPIFY_ALL,
' ', BEHAVIOR_CHIPIFY_ALL,
';', BEHAVIOR_CHIPIFY_ALL
)
);
nacho.enableEditChipOnTouch(true, true);
nacho.setOnFocusChangeListener((v, hasFocus)->nacho.chipifyAllUnterminatedTokens());
return nacho;
}
@SuppressLint("ClickableViewAccessibility")
protected void makeTimelineEditor(@Nullable TimelineDefinition item, Consumer<TimelineDefinition> onSave, Runnable onRemove){
Context ctx=getContext();
View view=getActivity().getLayoutInflater().inflate(R.layout.edit_timeline, list, false);
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));
LinearLayout tagWrap=view.findViewById(R.id.tag_wrap);
boolean hashtagOptionsAvailable=item==null || item.getType()==TimelineDefinition.TimelineType.HASHTAG;
advancedBtn.setVisibility(hashtagOptionsAvailable ? 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);
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);
view.findViewById(R.id.local_only).setOnClickListener(l->localOnlySwitch.setChecked(!localOnlySwitch.isChecked()));
EditText tagMain=view.findViewById(R.id.tag_main);
NachoTextView tagsAny=prepareChipTextView(view.findViewById(R.id.tags_any));
NachoTextView tagsAll=prepareChipTextView(view.findViewById(R.id.tags_all));
NachoTextView tagsNone=prepareChipTextView(view.findViewById(R.id.tags_none));
if(item!=null && hashtagOptionsAvailable){
tagMain.setText(item.getHashtagName());
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);
hasAdvanced=true;
}
if(hasAdvanced){
advancedBtn.setSelected(true);
advancedBtn.setText(R.string.sk_advanced_options_hide);
tagWrap.setVisibility(View.VISIBLE);
divider.setVisibility(View.VISIBLE);
}
}
ImageButton btn=view.findViewById(R.id.button);
PopupMenu popup=new PopupMenu(ctx, btn);
TimelineDefinition.Icon currentIcon=item!=null ? item.getIcon() : TimelineDefinition.Icon.HASHTAG;
btn.setImageResource(currentIcon.iconRes);
btn.setTag(currentIcon.ordinal());
btn.setContentDescription(ctx.getString(currentIcon.nameRes));
btn.setOnTouchListener(popup.getDragToOpenListener());
btn.setOnClickListener(l->popup.show());
Menu menu=popup.getMenu();
TimelineDefinition.Icon defaultIcon=item!=null ? item.getDefaultIcon() : TimelineDefinition.Icon.HASHTAG;
menu.add(0, currentIcon.ordinal(), NONE, currentIcon.nameRes).setIcon(currentIcon.iconRes);
if(!currentIcon.equals(defaultIcon)){
menu.add(0, defaultIcon.ordinal(), NONE, defaultIcon.nameRes).setIcon(defaultIcon.iconRes);
}
for(TimelineDefinition.Icon icon : TimelineDefinition.Icon.values()){
if(icon.hidden || icon.ordinal()==(int) btn.getTag()) continue;
menu.add(0, icon.ordinal(), NONE, icon.nameRes).setIcon(icon.iconRes);
}
UiUtils.enablePopupMenuIcons(ctx, popup);
popup.setOnMenuItemClickListener(menuItem->{
TimelineDefinition.Icon icon=TimelineDefinition.Icon.values()[menuItem.getItemId()];
btn.setImageResource(icon.iconRes);
btn.setTag(menuItem.getItemId());
btn.setContentDescription(ctx.getString(icon.nameRes));
return true;
});
AlertDialog.Builder builder=new M3AlertDialogBuilder(ctx)
.setTitle(item==null ? R.string.sk_add_timeline : R.string.sk_edit_timeline)
.setView(view)
.setPositiveButton(R.string.save, (d, which)->{
String name=editText.getText().toString().trim();
String mainHashtag=tagMain.getText().toString().trim();
if(item != null && item.getType()==TimelineDefinition.TimelineType.HASHTAG){
tagsAny.chipifyAllUnterminatedTokens();
tagsAll.chipifyAllUnterminatedTokens();
tagsNone.chipifyAllUnterminatedTokens();
if(TextUtils.isEmpty(mainHashtag)){
mainHashtag=name;
name=null;
}
if(TextUtils.isEmpty(mainHashtag) && (item!=null && item.getType()==TimelineDefinition.TimelineType.HASHTAG)){
Toast.makeText(ctx, R.string.sk_add_timeline_tag_error_empty, Toast.LENGTH_SHORT).show();
onSave.accept(null);
return;
}
}
TimelineDefinition tl=item!=null ? item : TimelineDefinition.ofHashtag(name);
TimelineDefinition.Icon icon=TimelineDefinition.Icon.values()[(int) btn.getTag()];
tl.setIcon(icon);
tl.setTitle(name);
if(item == null || item.getType()==TimelineDefinition.TimelineType.HASHTAG){
tl.setTagOptions(
mainHashtag,
tagsAny.getChipValues(),
tagsAll.getChipValues(),
tagsNone.getChipValues(),
localOnlySwitch.isChecked()
);
}
onSave.accept(tl);
})
.setNegativeButton(R.string.cancel, (d, which)->{});
if(onRemove!=null) builder.setNeutralButton(R.string.sk_remove, (d, which)->onRemove.run());
builder.show();
btn.requestFocus();
}
private class TimelinesAdapter extends RecyclerView.Adapter<TimelineViewHolder>{
@NonNull
@Override
public TimelineViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType){
return new TimelineViewHolder();
}
@Override
public void onBindViewHolder(@NonNull TimelineViewHolder holder, int position){
holder.bind(data.get(position));
}
@Override
public int getItemCount(){
return data.size();
}
}
private class TimelineViewHolder extends BindableViewHolder<TimelineDefinition> implements UsableRecyclerView.Clickable{
private final TextView title;
private final ImageView dragger;
public TimelineViewHolder(){
super(getActivity(), R.layout.item_text, list);
title=findViewById(R.id.title);
dragger=findViewById(R.id.dragger_thingy);
}
@SuppressLint("ClickableViewAccessibility")
@Override
public void onBind(TimelineDefinition item){
title.setText(item.getTitle(getContext()));
title.setCompoundDrawablesRelativeWithIntrinsicBounds(itemView.getContext().getDrawable(item.getIcon().iconRes), null, null, null);
dragger.setVisibility(View.VISIBLE);
dragger.setOnTouchListener((View v, MotionEvent event)->{
if(event.getAction()==MotionEvent.ACTION_DOWN){
itemTouchHelper.startDrag(this);
return true;
}
return false;
});
}
private void onSave(TimelineDefinition tl){
saveTimelines();
rebind();
}
private void onRemove(){
removeTimeline(getAbsoluteAdapterPosition());
}
@SuppressLint("ClickableViewAccessibility")
@Override
public void onClick(){
makeTimelineEditor(item, this::onSave, this::onRemove);
}
}
private class ItemTouchHelperCallback extends ItemTouchHelper.SimpleCallback{
public ItemTouchHelperCallback(){
super(ItemTouchHelper.UP|ItemTouchHelper.DOWN, ItemTouchHelper.LEFT|ItemTouchHelper.RIGHT);
}
@Override
public boolean onMove(@NonNull RecyclerView recyclerView, @NonNull RecyclerView.ViewHolder viewHolder, @NonNull RecyclerView.ViewHolder target){
int fromPosition=viewHolder.getAbsoluteAdapterPosition();
int toPosition=target.getAbsoluteAdapterPosition();
if(Math.max(fromPosition, toPosition)>=data.size() || Math.min(fromPosition, toPosition)<0){
return false;
}else{
Collections.swap(data, fromPosition, toPosition);
adapter.notifyItemMoved(fromPosition, toPosition);
saveTimelines();
return true;
}
}
@Override
public void onSelectedChanged(@Nullable RecyclerView.ViewHolder viewHolder, int actionState){
if(actionState==ItemTouchHelper.ACTION_STATE_DRAG && viewHolder!=null){
viewHolder.itemView.animate().alpha(0.65f);
}
}
@Override
public void clearView(@NonNull RecyclerView recyclerView, @NonNull RecyclerView.ViewHolder viewHolder){
super.clearView(recyclerView, viewHolder);
viewHolder.itemView.animate().alpha(1f);
}
@Override
public void onSwiped(@NonNull RecyclerView.ViewHolder viewHolder, int direction){
int position=viewHolder.getAbsoluteAdapterPosition();
removeTimeline(position);
}
}
}

View File

@@ -1,9 +1,11 @@
package org.joinmastodon.android.fragments;
import android.app.Activity;
import android.net.Uri;
import org.joinmastodon.android.R;
import org.joinmastodon.android.api.requests.statuses.GetFavoritedStatuses;
import org.joinmastodon.android.model.FilterContext;
import org.joinmastodon.android.model.HeaderPaginationList;
import org.joinmastodon.android.model.Status;
@@ -25,6 +27,7 @@ public class FavoritedStatusListFragment extends StatusListFragment{
.setCallback(new SimpleCallback<>(this){
@Override
public void onSuccess(HeaderPaginationList<Status> result){
if(getActivity()==null) return;
if(result.nextPageUri!=null)
nextMaxID=result.nextPageUri.getQueryParameter("max_id");
else
@@ -34,4 +37,16 @@ public class FavoritedStatusListFragment extends StatusListFragment{
})
.exec(accountID);
}
@Override
protected FilterContext getFilterContext() {
return FilterContext.ACCOUNT;
}
@Override
public Uri getWebUri(Uri.Builder base) {
return base.encodedPath(isInstanceAkkoma()
? '/' + getSession().self.username + "#favorites"
: "/favourites").build();
}
}

View File

@@ -2,6 +2,7 @@ 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;
@@ -55,4 +56,9 @@ public class FeaturedHashtagsListFragment extends BaseStatusListFragment<Hashtag
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
}
}

View File

@@ -0,0 +1,375 @@
package org.joinmastodon.android.fragments;
import android.app.Activity;
import android.graphics.Rect;
import android.graphics.drawable.Animatable;
import android.graphics.drawable.Drawable;
import android.net.Uri;
import android.os.Bundle;
import android.text.SpannableStringBuilder;
import android.text.TextUtils;
import android.view.View;
import android.view.ViewGroup;
import android.widget.ImageView;
import android.widget.ProgressBar;
import android.widget.TextView;
import org.joinmastodon.android.R;
import org.joinmastodon.android.api.requests.accounts.GetAccountRelationships;
import org.joinmastodon.android.api.requests.accounts.GetFollowRequests;
import org.joinmastodon.android.api.session.AccountSessionManager;
import org.joinmastodon.android.model.Account;
import org.joinmastodon.android.model.HeaderPaginationList;
import org.joinmastodon.android.model.Relationship;
import org.joinmastodon.android.ui.OutlineProviders;
import org.joinmastodon.android.ui.text.HtmlParser;
import org.joinmastodon.android.ui.utils.CustomEmojiHelper;
import org.joinmastodon.android.ui.utils.UiUtils;
import org.joinmastodon.android.ui.views.ProgressBarButton;
import org.joinmastodon.android.utils.ProvidesAssistContent;
import org.parceler.Parcels;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.function.Function;
import java.util.stream.Collectors;
import androidx.annotation.NonNull;
import androidx.recyclerview.widget.RecyclerView;
import me.grishka.appkit.Nav;
import me.grishka.appkit.api.Callback;
import me.grishka.appkit.api.ErrorResponse;
import me.grishka.appkit.api.SimpleCallback;
import me.grishka.appkit.imageloader.ImageLoaderRecyclerAdapter;
import me.grishka.appkit.imageloader.ImageLoaderViewHolder;
import me.grishka.appkit.imageloader.requests.ImageLoaderRequest;
import me.grishka.appkit.imageloader.requests.UrlImageLoaderRequest;
import me.grishka.appkit.utils.BindableViewHolder;
import me.grishka.appkit.utils.V;
import me.grishka.appkit.views.UsableRecyclerView;
public class FollowRequestsListFragment extends MastodonRecyclerFragment<FollowRequestsListFragment.AccountWrapper> implements ScrollableToTop, ProvidesAssistContent.ProvidesWebUri {
private String accountID;
private Map<String, Relationship> relationships=Collections.emptyMap();
private GetAccountRelationships relationshipsRequest;
private String nextMaxID;
public FollowRequestsListFragment(){
super(20);
}
@Override
public void onCreate(Bundle savedInstanceState){
super.onCreate(savedInstanceState);
accountID=getArguments().getString("account");
loadData();
}
@Override
public void onAttach(Activity activity) {
super.onAttach(activity);
setTitle(R.string.sk_follow_requests);
}
@Override
protected void doLoadData(int offset, int count){
if(relationshipsRequest!=null){
relationshipsRequest.cancel();
relationshipsRequest=null;
}
currentRequest=new GetFollowRequests(offset==0 ? null : nextMaxID, count)
.setCallback(new SimpleCallback<>(this){
@Override
public void onSuccess(HeaderPaginationList<Account> result){
if(getActivity()==null) return;
if(result.nextPageUri!=null)
nextMaxID=result.nextPageUri.getQueryParameter("max_id");
else
nextMaxID=null;
onDataLoaded(result.stream().map(AccountWrapper::new).collect(Collectors.toList()), false);
loadRelationships();
}
})
.exec(accountID);
}
@Override
protected RecyclerView.Adapter getAdapter(){
return new AccountsAdapter();
}
@Override
public void onViewCreated(View view, Bundle savedInstanceState){
super.onViewCreated(view, savedInstanceState);
list.addItemDecoration(new RecyclerView.ItemDecoration(){
@Override
public void getItemOffsets(@NonNull Rect outRect, @NonNull View view, @NonNull RecyclerView parent, @NonNull RecyclerView.State state){
outRect.bottom=outRect.left=outRect.right=V.dp(16);
if(parent.getChildAdapterPosition(view)==0)
outRect.top=V.dp(16);
}
});
((UsableRecyclerView)list).setDrawSelectorOnTop(true);
}
private void loadRelationships(){
relationships=Collections.emptyMap();
relationshipsRequest=new GetAccountRelationships(data.stream().map(fs->fs.account.id).collect(Collectors.toList()));
relationshipsRequest.setCallback(new Callback<>(){
@Override
public void onSuccess(List<Relationship> result){
relationshipsRequest=null;
relationships=result.stream().collect(Collectors.toMap(rel->rel.id, Function.identity()));
if(list==null)
return;
for(int i=0;i<list.getChildCount();i++){
RecyclerView.ViewHolder holder=list.getChildViewHolder(list.getChildAt(i));
if(holder instanceof AccountViewHolder avh)
avh.rebind();
}
}
@Override
public void onError(ErrorResponse error){
relationshipsRequest=null;
}
}).exec(accountID);
}
@Override
public void onDestroyView(){
super.onDestroyView();
if(relationshipsRequest!=null){
relationshipsRequest.cancel();
relationshipsRequest=null;
}
}
@Override
public void scrollToTop(){
smoothScrollRecyclerViewToTop(list);
}
@Override
public String getAccountID() {
return accountID;
}
@Override
public Uri getWebUri(Uri.Builder base) {
return base.path(isInstanceAkkoma() ? "/friend-requests" : "/follow_requests").build();
}
private class AccountsAdapter extends UsableRecyclerView.Adapter<AccountViewHolder> implements ImageLoaderRecyclerAdapter{
public AccountsAdapter(){
super(imgLoader);
}
@Override
public void onBindViewHolder(AccountViewHolder holder, int position){
holder.bind(data.get(position));
super.onBindViewHolder(holder, position);
}
@NonNull
@Override
public AccountViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType){
return new AccountViewHolder();
}
@Override
public int getItemCount(){
return data.size();
}
@Override
public int getImageCountForItem(int position){
return 2+data.get(position).emojiHelper.getImageCount();
}
@Override
public ImageLoaderRequest getImageRequest(int position, int image){
AccountWrapper item=data.get(position);
if(image==0)
return item.avaRequest;
else if(image==1)
return item.coverRequest;
else
return item.emojiHelper.getImageRequest(image-2);
}
}
// literally the same as AccountCardStatusDisplayItem and DiscoverAccountsFragment. code should be generalized
private class AccountViewHolder extends BindableViewHolder<AccountWrapper> implements ImageLoaderViewHolder, UsableRecyclerView.DisableableClickable{
private final ImageView cover, avatar;
private final TextView name, username, bio, followersCount, followingCount, postsCount, followersLabel, followingLabel, postsLabel;
private final ProgressBarButton actionButton, acceptButton, rejectButton;
private final ProgressBar actionProgress, acceptProgress, rejectProgress;
private final View actionWrap, acceptWrap, rejectWrap;
private Relationship relationship;
public AccountViewHolder(){
super(getActivity(), R.layout.item_discover_account, list);
cover=findViewById(R.id.cover);
avatar=findViewById(R.id.avatar);
name=findViewById(R.id.name);
username=findViewById(R.id.username);
bio=findViewById(R.id.bio);
followersCount=findViewById(R.id.followers_count);
followersLabel=findViewById(R.id.followers_label);
followingCount=findViewById(R.id.following_count);
followingLabel=findViewById(R.id.following_label);
postsCount=findViewById(R.id.posts_count);
postsLabel=findViewById(R.id.posts_label);
actionButton=findViewById(R.id.action_btn);
actionProgress=findViewById(R.id.action_progress);
actionWrap=findViewById(R.id.action_btn_wrap);
acceptButton=findViewById(R.id.accept_btn);
acceptProgress=findViewById(R.id.accept_progress);
acceptWrap=findViewById(R.id.accept_btn_wrap);
rejectButton=findViewById(R.id.reject_btn);
rejectProgress=findViewById(R.id.reject_progress);
rejectWrap=findViewById(R.id.reject_btn_wrap);
avatar.setOutlineProvider(OutlineProviders.roundedRect(15));
avatar.setClipToOutline(true);
View border=findViewById(R.id.avatar_border);
border.setOutlineProvider(OutlineProviders.roundedRect(17));
border.setClipToOutline(true);
cover.setOutlineProvider(OutlineProviders.roundedRect(9));
cover.setClipToOutline(true);
itemView.setOutlineProvider(OutlineProviders.roundedRect(12));
itemView.setClipToOutline(true);
actionButton.setOnClickListener(this::onActionButtonClick);
acceptButton.setOnClickListener(this::onFollowRequestButtonClick);
rejectButton.setOnClickListener(this::onFollowRequestButtonClick);
itemView.setOnClickListener(v->this.onClick());
}
@Override
public boolean isEnabled(){
return false;
}
@Override
public void onBind(AccountWrapper item){
name.setText(item.parsedName);
username.setText('@'+item.account.acct);
bio.setText(item.parsedBio);
followersCount.setText(UiUtils.abbreviateNumber(item.account.followersCount));
followingCount.setText(UiUtils.abbreviateNumber(item.account.followingCount));
postsCount.setText(UiUtils.abbreviateNumber(item.account.statusesCount));
followersLabel.setText(getResources().getQuantityString(R.plurals.followers, (int)Math.min(999, item.account.followersCount)));
followingLabel.setText(getResources().getQuantityString(R.plurals.following, (int)Math.min(999, item.account.followingCount)));
postsLabel.setText(getResources().getQuantityString(R.plurals.sk_posts_count_label, (int)(item.account.statusesCount%1000), item.account.statusesCount));
followersCount.setVisibility(item.account.followersCount < 0 ? View.GONE : View.VISIBLE);
followersLabel.setVisibility(item.account.followersCount < 0 ? View.GONE : View.VISIBLE);
followingCount.setVisibility(item.account.followingCount < 0 ? View.GONE : View.VISIBLE);
followingLabel.setVisibility(item.account.followingCount < 0 ? View.GONE : View.VISIBLE);
relationship=relationships.get(item.account.id);
UiUtils.setExtraTextInfo(getContext(), null, true, false, false, item.account);
if(relationship==null || !relationship.followedBy){
actionWrap.setVisibility(View.GONE);
acceptWrap.setVisibility(View.VISIBLE);
rejectWrap.setVisibility(View.VISIBLE);
acceptButton.setCompoundDrawableTintList(acceptButton.getTextColors());
acceptProgress.setIndeterminateTintList(acceptButton.getTextColors());
rejectButton.setCompoundDrawableTintList(rejectButton.getTextColors());
rejectProgress.setIndeterminateTintList(rejectButton.getTextColors());
}else{
actionWrap.setVisibility(View.VISIBLE);
acceptWrap.setVisibility(View.GONE);
rejectWrap.setVisibility(View.GONE);
UiUtils.setRelationshipToActionButtonM3(relationship, actionButton);
}
}
@Override
public void setImage(int index, Drawable image){
if(index==0){
avatar.setImageDrawable(image);
}else if(index==1){
cover.setImageDrawable(image);
}else{
item.emojiHelper.setImageDrawable(index-2, image);
name.invalidate();
bio.invalidate();
}
if(image instanceof Animatable a && !a.isRunning())
a.start();
}
@Override
public void clearImage(int index){
setImage(index, null);
}
@Override
public void onClick(){
Bundle args=new Bundle();
args.putString("account", accountID);
args.putParcelable("profileAccount", Parcels.wrap(item.account));
Nav.go(getActivity(), ProfileFragment.class, args);
}
private void onFollowRequestButtonClick(View v) {
itemView.setHasTransientState(true);
UiUtils.handleFollowRequest((Activity) v.getContext(), item.account, accountID, null, v == acceptButton, relationship, rel -> {
if(getContext()==null) return;
itemView.setHasTransientState(false);
relationships.put(item.account.id, rel);
RecyclerView.Adapter<? extends RecyclerView.ViewHolder> adapter = getBindingAdapter();
if (!rel.requested && !rel.followedBy && adapter != null) {
data.remove(item);
adapter.notifyItemRemoved(getLayoutPosition());
} else {
rebind();
}
});
}
private void onActionButtonClick(View v){
itemView.setHasTransientState(true);
UiUtils.performAccountAction(getActivity(), item.account, accountID, relationship, actionButton, this::setActionProgressVisible, rel->{
if(getContext()==null) return;
itemView.setHasTransientState(false);
relationships.put(item.account.id, rel);
rebind();
});
}
private void setActionProgressVisible(boolean visible){
actionButton.setTextVisible(!visible);
actionProgress.setVisibility(visible ? View.VISIBLE : View.GONE);
actionButton.setClickable(!visible);
}
}
protected class AccountWrapper{
public Account account;
public ImageLoaderRequest avaRequest, coverRequest;
public CustomEmojiHelper emojiHelper=new CustomEmojiHelper();
public CharSequence parsedName, parsedBio;
public AccountWrapper(Account account){
this.account=account;
avaRequest=new UrlImageLoaderRequest(
TextUtils.isEmpty(account.avatar) ? AccountSessionManager.get(getAccountID()).getDefaultAvatarUrl() : account.avatar,
V.dp(50), V.dp(50));
if(!TextUtils.isEmpty(account.header))
coverRequest=new UrlImageLoaderRequest(account.header, 1000, 1000);
parsedBio=HtmlParser.parse(account.note, account.emojis, Collections.emptyList(), Collections.emptyList(), accountID);
if(account.emojis.isEmpty()){
parsedName= account.getDisplayName();
}else{
parsedName=HtmlParser.parseCustomEmoji(account.getDisplayName(), account.emojis);
emojiHelper.setText(new SpannableStringBuilder(parsedName).append(parsedBio));
}
}
}
}

View File

@@ -0,0 +1,128 @@
package org.joinmastodon.android.fragments;
import android.net.Uri;
import android.os.Bundle;
import android.view.View;
import android.view.ViewGroup;
import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.recyclerview.widget.RecyclerView;
import org.joinmastodon.android.R;
import org.joinmastodon.android.api.requests.tags.GetFollowedHashtags;
import org.joinmastodon.android.model.Hashtag;
import org.joinmastodon.android.model.HeaderPaginationList;
import org.joinmastodon.android.ui.DividerItemDecoration;
import org.joinmastodon.android.ui.utils.UiUtils;
import org.joinmastodon.android.utils.ProvidesAssistContent;
import me.grishka.appkit.api.SimpleCallback;
import me.grishka.appkit.utils.BindableViewHolder;
import me.grishka.appkit.views.UsableRecyclerView;
public class FollowedHashtagsFragment extends MastodonRecyclerFragment<Hashtag> implements ScrollableToTop, ProvidesAssistContent.ProvidesWebUri {
private String nextMaxID;
private String accountID;
public FollowedHashtagsFragment() {
super(20);
}
@Override
public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
Bundle args=getArguments();
accountID=args.getString("account");
setTitle(R.string.sk_hashtags_you_follow);
}
@Override
protected void onShown(){
super.onShown();
if(!getArguments().getBoolean("noAutoLoad") && !loaded && !dataLoading)
loadData();
}
@Override
public void onViewCreated(View view, Bundle savedInstanceState) {
super.onViewCreated(view, savedInstanceState);
list.addItemDecoration(new DividerItemDecoration(getActivity(), R.attr.colorM3OutlineVariant, 0.5f, 56, 16));
}
@Override
protected void doLoadData(int offset, int count){
currentRequest=new GetFollowedHashtags(offset==0 ? null : nextMaxID, null, count, null)
.setCallback(new SimpleCallback<>(this){
@Override
public void onSuccess(HeaderPaginationList<Hashtag> result){
if(getActivity()==null) return;
if(result.nextPageUri!=null)
nextMaxID=result.nextPageUri.getQueryParameter("max_id");
else
nextMaxID=null;
onDataLoaded(result, nextMaxID!=null);
}
})
.exec(accountID);
}
@Override
protected RecyclerView.Adapter getAdapter() {
return new HashtagsAdapter();
}
@Override
public void scrollToTop() {
smoothScrollRecyclerViewToTop(list);
}
@Override
public String getAccountID() {
return accountID;
}
@Override
public Uri getWebUri(Uri.Builder base) {
return isInstanceAkkoma() ? null : base.path("/followed_tags").build();
}
private class HashtagsAdapter extends RecyclerView.Adapter<HashtagViewHolder>{
@NonNull
@Override
public HashtagViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType){
return new HashtagViewHolder();
}
@Override
public void onBindViewHolder(@NonNull HashtagViewHolder holder, int position) {
holder.bind(data.get(position));
}
@Override
public int getItemCount() {
return data.size();
}
}
private class HashtagViewHolder extends BindableViewHolder<Hashtag> implements UsableRecyclerView.Clickable{
private final TextView title;
public HashtagViewHolder(){
super(getActivity(), R.layout.item_text, list);
title=findViewById(R.id.title);
}
@Override
public void onBind(Hashtag item) {
title.setText(item.name);
title.setCompoundDrawablesRelativeWithIntrinsicBounds(itemView.getContext().getDrawable(R.drawable.ic_fluent_number_symbol_24_regular), null, null, null);
}
@Override
public void onClick() {
UiUtils.openHashtagTimeline(getActivity(), accountID, item.name);
}
}
}

View File

@@ -0,0 +1,32 @@
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;
import java.util.Optional;
public interface HasAccountID {
String getAccountID();
default AccountSession getSession() {
return AccountSessionManager.getInstance().getAccount(getAccountID());
}
default boolean isInstanceAkkoma() {
return getInstance().map(Instance::isAkkoma).orElse(false);
}
default boolean isInstancePixelfed() {
return getInstance().map(Instance::isPixelfed).orElse(false);
}
default Optional<Instance> getInstance() {
return getSession().getInstance();
}
default AccountLocalPreferences getLocalPrefs() {
return AccountSessionManager.get(getAccountID()).getLocalPreferences();
}
}

View File

@@ -0,0 +1,7 @@
package org.joinmastodon.android.fragments;
import org.joinmastodon.android.utils.ElevationOnScrollListener;
public interface HasElevationOnScrollListener {
ElevationOnScrollListener getElevationOnScrollListener();
}

View File

@@ -0,0 +1,10 @@
package org.joinmastodon.android.fragments;
import android.view.View;
public interface HasFab {
View getFab();
void showFab();
void hideFab();
boolean isScrolling();
}

View File

@@ -2,8 +2,10 @@ package org.joinmastodon.android.fragments;
import android.app.Activity;
import android.content.res.TypedArray;
import android.net.Uri;
import android.os.Bundle;
import android.text.SpannableStringBuilder;
import android.view.HapticFeedbackConstants;
import android.view.Menu;
import android.view.MenuInflater;
import android.view.MenuItem;
@@ -12,52 +14,82 @@ import android.view.ViewGroup;
import android.widget.ImageButton;
import android.widget.ProgressBar;
import android.widget.TextView;
import android.widget.Toast;
import androidx.annotation.NonNull;
import androidx.recyclerview.widget.RecyclerView;
import org.joinmastodon.android.R;
import org.joinmastodon.android.api.MastodonErrorResponse;
import org.joinmastodon.android.api.requests.filters.CreateFilter;
import org.joinmastodon.android.api.requests.filters.DeleteFilter;
import org.joinmastodon.android.api.requests.filters.GetFilters;
import org.joinmastodon.android.api.requests.tags.GetTag;
import org.joinmastodon.android.api.requests.tags.SetTagFollowed;
import org.joinmastodon.android.api.requests.timelines.GetHashtagTimeline;
import org.joinmastodon.android.model.Filter;
import org.joinmastodon.android.model.FilterAction;
import org.joinmastodon.android.api.session.AccountSessionManager;
import org.joinmastodon.android.model.FilterContext;
import org.joinmastodon.android.model.FilterKeyword;
import org.joinmastodon.android.api.session.AccountSessionManager;
import org.joinmastodon.android.model.FilterContext;
import org.joinmastodon.android.model.Hashtag;
import org.joinmastodon.android.model.Status;
import org.joinmastodon.android.model.TimelineDefinition;
import org.joinmastodon.android.ui.text.SpacerSpan;
import org.joinmastodon.android.ui.utils.UiUtils;
import org.joinmastodon.android.ui.views.ProgressBarButton;
import org.parceler.Parcels;
import java.util.ArrayList;
import java.util.EnumSet;
import java.util.List;
import java.util.Optional;
import java.util.stream.Collectors;
import androidx.annotation.NonNull;
import androidx.recyclerview.widget.RecyclerView;
import me.grishka.appkit.Nav;
import me.grishka.appkit.api.Callback;
import me.grishka.appkit.api.ErrorResponse;
import me.grishka.appkit.api.SimpleCallback;
import me.grishka.appkit.utils.CubicBezierInterpolator;
import me.grishka.appkit.utils.MergeRecyclerAdapter;
import me.grishka.appkit.utils.SingleViewRecyclerAdapter;
import me.grishka.appkit.utils.V;
public class HashtagTimelineFragment extends StatusListFragment{
public class HashtagTimelineFragment extends PinnableStatusListFragment{
private Hashtag hashtag;
private String hashtagName;
private ImageButton fab;
private TextView headerTitle, headerSubtitle;
private ProgressBarButton followButton;
private ProgressBar followProgress;
private MenuItem followMenuItem;
private MenuItem followMenuItem, pinMenuItem, muteMenuItem;
private boolean followRequestRunning;
private boolean toolbarContentVisible;
private String maxID;
public HashtagTimelineFragment(){
setListLayoutId(R.layout.recycler_fragment_with_fab);
private List<String> any;
private List<String> all;
private List<String> none;
private boolean following;
private boolean localOnly;
private Menu optionsMenu;
private MenuInflater optionsMenuInflater;
private Optional<Filter> filter = Optional.empty();
@Override
protected boolean wantsComposeButton() {
return true;
}
@Override
public void onAttach(Activity activity){
super.onAttach(activity);
following=getArguments().getBoolean("following", false);
localOnly=getArguments().getBoolean("localOnly", false);
any=getArguments().getStringArrayList("any");
all=getArguments().getStringArrayList("all");
none=getArguments().getStringArrayList("none");
if(getArguments().containsKey("hashtag")){
hashtag=Parcels.unwrap(getArguments().getParcelable("hashtag"));
hashtagName=hashtag.name;
@@ -68,16 +100,71 @@ public class HashtagTimelineFragment extends StatusListFragment{
setHasOptionsMenu(true);
}
private void updateMuteState(boolean newMute) {
muteMenuItem.setTitle(getString(newMute ? R.string.unmute_user : R.string.mute_user, "#" + hashtag));
muteMenuItem.setIcon(newMute ? R.drawable.ic_fluent_speaker_2_24_regular : R.drawable.ic_fluent_speaker_off_24_regular);
}
private void showMuteDialog(boolean mute) {
UiUtils.showConfirmationAlert(getContext(),
mute ? R.string.mo_unmute_hashtag : R.string.mo_mute_hashtag,
mute ? R.string.mo_confirm_to_unmute_hashtag : R.string.mo_confirm_to_mute_hashtag,
mute ? R.string.do_unmute : R.string.do_mute,
mute ? R.drawable.ic_fluent_speaker_2_28_regular : R.drawable.ic_fluent_speaker_off_28_regular,
mute ? this::unmuteHashtag : this::muteHashtag
);
}
private void unmuteHashtag() {
//safe to get, this only called if filter is present
new DeleteFilter(filter.get().id).setCallback(new Callback<>(){
@Override
public void onSuccess(Void result){
filter=Optional.empty();
updateMuteState(false);
}
@Override
public void onError(ErrorResponse error){
error.showToast(getContext());
}
}).exec(accountID);
}
private void muteHashtag() {
FilterKeyword hashtagFilter=new FilterKeyword();
hashtagFilter.wholeWord=true;
hashtagFilter.keyword="#"+hashtagName;
new CreateFilter("#"+hashtagName, EnumSet.of(FilterContext.HOME), FilterAction.HIDE, 0 , List.of(hashtagFilter)).setCallback(new Callback<>(){
@Override
public void onSuccess(Filter result){
filter=Optional.of(result);
updateMuteState(true);
}
@Override
public void onError(ErrorResponse error){
error.showToast(getContext());
}
}).exec(accountID);
}
@Override
protected TimelineDefinition makeTimelineDefinition() {
return TimelineDefinition.ofHashtag(hashtagName);
}
@Override
protected void doLoadData(int offset, int count){
currentRequest=new GetHashtagTimeline(hashtagName, offset==0 ? null : maxID, null, count)
currentRequest=new GetHashtagTimeline(hashtagName, getMaxID(), null, count, any, all, none, localOnly, getLocalPrefs().timelineReplyVisibility)
.setCallback(new SimpleCallback<>(this){
@Override
public void onSuccess(List<Status> result){
if(!result.isEmpty())
maxID=result.get(result.size()-1).id;
AccountSessionManager.get(accountID).filterStatuses(result, FilterContext.PUBLIC);
onDataLoaded(result, !result.isEmpty());
if(getActivity()==null) return;
boolean more=applyMaxID(result);
AccountSessionManager.get(accountID).filterStatuses(result, getFilterContext());
onDataLoaded(result, more);
}
})
.exec(accountID);
@@ -102,6 +189,8 @@ public class HashtagTimelineFragment extends StatusListFragment{
fab=view.findViewById(R.id.fab);
fab.setOnClickListener(this::onFabClick);
if(getParentFragment() instanceof HomeTabFragment) return;
list.addOnScrollListener(new RecyclerView.OnScrollListener(){
@Override
public void onScrolled(@NonNull RecyclerView recyclerView, int dx, int dy){
@@ -112,14 +201,19 @@ public class HashtagTimelineFragment extends StatusListFragment{
boolean newToolbarVisibility=newAlpha>0.5f;
if(newToolbarVisibility!=toolbarContentVisible){
toolbarContentVisible=newToolbarVisibility;
if(followMenuItem!=null)
followMenuItem.setVisible(toolbarContentVisible);
createOptionsMenu();
}
}
});
}
private void onFabClick(View v){
@Override
public boolean onFabLongClick(View v) {
return UiUtils.pickAccountForCompose(getActivity(), accountID, '#'+hashtagName+' ');
}
@Override
public void onFabClick(View v){
Bundle args=new Bundle();
args.putString("account", accountID);
args.putString("prefilledText", '#'+hashtagName+' ');
@@ -131,6 +225,16 @@ public class HashtagTimelineFragment extends StatusListFragment{
((ViewGroup.MarginLayoutParams) fab.getLayoutParams()).bottomMargin=V.dp(16)+inset;
}
@Override
protected FilterContext getFilterContext() {
return FilterContext.PUBLIC;
}
@Override
public Uri getWebUri(Uri.Builder base) {
return base.path((isInstanceAkkoma() ? "/tag/" : "/tags/") + hashtag).build();
}
@Override
protected RecyclerView.Adapter getAdapter(){
View header=getActivity().getLayoutInflater().inflate(R.layout.header_hashtag_timeline, list, false);
@@ -146,10 +250,33 @@ public class HashtagTimelineFragment extends StatusListFragment{
return;
setFollowed(!hashtag.following);
});
followButton.setOnLongClickListener(v->{
if(hashtag==null) return false;
UiUtils.pickAccount(getActivity(), accountID, R.string.sk_follow_as, R.drawable.ic_fluent_person_add_28_regular, session -> {
new SetTagFollowed(hashtagName, true).setCallback(new Callback<>(){
@Override
public void onSuccess(Hashtag hashtag) {
Toast.makeText(
getActivity(),
getString(R.string.sk_followed_as, session.self.getShortUsername()),
Toast.LENGTH_SHORT
).show();
}
@Override
public void onError(ErrorResponse error) {
error.showToast(getActivity());
}
}).exec(session.getID());
}, null);
return true;
});
updateHeader();
MergeRecyclerAdapter mergeAdapter=new MergeRecyclerAdapter();
mergeAdapter.addAdapter(new SingleViewRecyclerAdapter(header));
if(!(getParentFragment() instanceof HomeTabFragment)){
mergeAdapter.addAdapter(new SingleViewRecyclerAdapter(header));
}
mergeAdapter.addAdapter(super.getAdapter());
return mergeAdapter;
}
@@ -159,16 +286,55 @@ public class HashtagTimelineFragment extends StatusListFragment{
return 1;
}
private void createOptionsMenu(){
optionsMenu.clear();
optionsMenuInflater.inflate(R.menu.hashtag_timeline, optionsMenu);
followMenuItem=optionsMenu.findItem(R.id.follow_hashtag);
pinMenuItem=optionsMenu.findItem(R.id.pin);
followMenuItem.setVisible(toolbarContentVisible);
// pinMenuItem.setShowAsAction(toolbarContentVisible ? MenuItem.SHOW_AS_ACTION_NEVER : MenuItem.SHOW_AS_ACTION_ALWAYS);
super.updatePinButton(pinMenuItem);
muteMenuItem = optionsMenu.findItem(R.id.mute_hashtag);
updateMuteState(filter.isPresent());
new GetFilters().setCallback(new Callback<>() {
@Override
public void onSuccess(List<Filter> filters) {
if (getActivity() == null) return;
filter=filters.stream().filter(filter->filter.title.equals("#"+hashtagName)).findAny();
updateMuteState(filter.isPresent());
}
@Override
public void onError(ErrorResponse error) {
error.showToast(getActivity());
}
}).exec(accountID);
}
@Override
public void updatePinButton(MenuItem pin){
super.updatePinButton(pin);
if(toolbarContentVisible) UiUtils.insetPopupMenuIcon(getContext(), pin);
}
@Override
public void onCreateOptionsMenu(Menu menu, MenuInflater inflater){
followMenuItem=menu.add(getString(hashtag!=null && hashtag.following ? R.string.unfollow_user : R.string.follow_user, "#"+hashtagName));
followMenuItem.setVisible(toolbarContentVisible);
inflater.inflate(R.menu.hashtag_timeline, menu);
super.onCreateOptionsMenu(menu, inflater);
optionsMenu=menu;
optionsMenuInflater=inflater;
createOptionsMenu();
}
@Override
public boolean onOptionsItemSelected(MenuItem item){
if(hashtag!=null){
if (super.onOptionsItemSelected(item)) return true;
if (item.getItemId() == R.id.follow_hashtag && hashtag!=null) {
setFollowed(!hashtag.following);
} else if (item.getItemId() == R.id.mute_hashtag) {
showMuteDialog(filter.isPresent());
return true;
}
return true;
}
@@ -177,8 +343,7 @@ public class HashtagTimelineFragment extends StatusListFragment{
protected void onUpdateToolbar(){
super.onUpdateToolbar();
toolbarTitleView.setAlpha(toolbarContentVisible ? 1f : 0f);
if(followMenuItem!=null)
followMenuItem.setVisible(toolbarContentVisible);
createOptionsMenu();
}
private void updateHeader(){
@@ -224,6 +389,11 @@ public class HashtagTimelineFragment extends StatusListFragment{
followProgress.setVisibility(View.GONE);
if(followMenuItem!=null){
followMenuItem.setTitle(getString(hashtag.following ? R.string.unfollow_user : R.string.follow_user, "#"+hashtagName));
followMenuItem.setIcon(hashtag.following ? R.drawable.ic_fluent_person_delete_24_filled : R.drawable.ic_fluent_person_add_24_regular);
}
if(muteMenuItem!=null){
muteMenuItem.setTitle(getString(filter.isPresent() ? R.string.unmute_user : R.string.mute_user, "#" + hashtag));
muteMenuItem.setIcon(filter.isPresent() ? R.drawable.ic_fluent_speaker_2_24_regular : R.drawable.ic_fluent_speaker_off_24_regular);
}
}
@@ -247,6 +417,7 @@ public class HashtagTimelineFragment extends StatusListFragment{
private void setFollowed(boolean followed){
if(followRequestRunning)
return;
getToolbar().performHapticFeedback(HapticFeedbackConstants.CONTEXT_CLICK);
followButton.setTextVisible(false);
followProgress.setVisibility(View.VISIBLE);
followRequestRunning=true;

View File

@@ -3,8 +3,13 @@ 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;
import android.os.Bundle;
import android.service.notification.StatusBarNotification;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
@@ -15,11 +20,13 @@ 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.BuildConfig;
import org.joinmastodon.android.E;
import org.joinmastodon.android.PushNotificationReceiver;
import org.joinmastodon.android.GlobalUserPreferences;
import org.joinmastodon.android.R;
import org.joinmastodon.android.api.session.AccountSession;
import org.joinmastodon.android.api.session.AccountSessionManager;
@@ -30,18 +37,17 @@ import org.joinmastodon.android.fragments.onboarding.OnboardingFollowSuggestions
import org.joinmastodon.android.model.Account;
import org.joinmastodon.android.model.Notification;
import org.joinmastodon.android.model.PaginatedResponse;
import org.joinmastodon.android.ui.sheets.AccountSwitcherSheet;
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.List;
import androidx.annotation.IdRes;
import androidx.annotation.Nullable;
import me.grishka.appkit.FragmentStackActivity;
import me.grishka.appkit.Nav;
import me.grishka.appkit.api.Callback;
@@ -54,11 +60,11 @@ import me.grishka.appkit.imageloader.requests.UrlImageLoaderRequest;
import me.grishka.appkit.utils.V;
import me.grishka.appkit.views.FragmentRootLinearLayout;
public class HomeFragment extends AppKitFragment implements OnBackPressedListener{
public class HomeFragment extends AppKitFragment implements OnBackPressedListener, ProvidesAssistContent, HasAccountID {
private FragmentRootLinearLayout content;
private HomeTimelineFragment homeTimelineFragment;
private NotificationsListFragment notificationsFragment;
private DiscoverFragment searchFragment;
private HomeTabFragment homeTabFragment;
private NotificationsFragment notificationsFragment;
private DiscoverFragment discoverFragment;
private ProfileFragment profileFragment;
private TabBar tabBar;
private View tabBarWrap;
@@ -73,7 +79,7 @@ public class HomeFragment extends AppKitFragment implements OnBackPressedListene
public void onCreate(Bundle savedInstanceState){
super.onCreate(savedInstanceState);
accountID=getArguments().getString("account");
setTitle(R.string.app_name);
setTitle(R.string.mo_app_name);
if(Build.VERSION.SDK_INT>=Build.VERSION_CODES.N)
setRetainInstance(true);
@@ -81,13 +87,13 @@ public class HomeFragment extends AppKitFragment implements OnBackPressedListene
if(savedInstanceState==null){
Bundle args=new Bundle();
args.putString("account", accountID);
homeTimelineFragment=new HomeTimelineFragment();
homeTimelineFragment.setArguments(args);
homeTabFragment=new HomeTabFragment();
homeTabFragment.setArguments(args);
args=new Bundle(args);
args.putBoolean("noAutoLoad", true);
searchFragment=new DiscoverFragment();
searchFragment.setArguments(args);
notificationsFragment=new NotificationsListFragment();
discoverFragment=new DiscoverFragment();
discoverFragment.setArguments(args);
notificationsFragment=new NotificationsFragment();
notificationsFragment.setArguments(args);
args=new Bundle(args);
args.putParcelable("profileAccount", Parcels.wrap(AccountSessionManager.getInstance().getAccount(accountID).self));
@@ -112,7 +118,7 @@ public class HomeFragment extends AppKitFragment implements OnBackPressedListene
content.setOrientation(LinearLayout.VERTICAL);
FrameLayout fragmentContainer=new FrameLayout(getActivity());
fragmentContainer.setId(R.id.fragment_wrap);
fragmentContainer.setId(me.grishka.appkit.R.id.fragment_wrap);
content.addView(fragmentContainer, new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 0, 1f));
inflater.inflate(R.layout.tab_bar, content);
@@ -120,6 +126,34 @@ public class HomeFragment extends AppKitFragment implements OnBackPressedListene
tabBar.setListeners(this::onTabSelected, this::onTabLongClick);
tabBarWrap=content.findViewById(R.id.tabbar_wrap);
// 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);
@@ -131,10 +165,10 @@ public class HomeFragment extends AppKitFragment implements OnBackPressedListene
if(savedInstanceState==null){
getChildFragmentManager().beginTransaction()
.add(R.id.fragment_wrap, homeTimelineFragment)
.add(R.id.fragment_wrap, searchFragment).hide(searchFragment)
.add(R.id.fragment_wrap, notificationsFragment).hide(notificationsFragment)
.add(R.id.fragment_wrap, profileFragment).hide(profileFragment)
.add(me.grishka.appkit.R.id.fragment_wrap, homeTabFragment)
.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();
String defaultTab=getArguments().getString("tab");
@@ -158,18 +192,17 @@ public class HomeFragment extends AppKitFragment implements OnBackPressedListene
@Override
public void onViewStateRestored(Bundle savedInstanceState){
super.onViewStateRestored(savedInstanceState);
if(savedInstanceState==null || homeTimelineFragment!=null)
return;
homeTimelineFragment=(HomeTimelineFragment) getChildFragmentManager().getFragment(savedInstanceState, "homeTimelineFragment");
searchFragment=(DiscoverFragment) getChildFragmentManager().getFragment(savedInstanceState, "searchFragment");
notificationsFragment=(NotificationsListFragment) getChildFragmentManager().getFragment(savedInstanceState, "notificationsFragment");
if(savedInstanceState==null) return;
homeTabFragment=(HomeTabFragment) getChildFragmentManager().getFragment(savedInstanceState, "homeTabFragment");
discoverFragment=(DiscoverFragment) getChildFragmentManager().getFragment(savedInstanceState, "searchFragment");
notificationsFragment=(NotificationsFragment) getChildFragmentManager().getFragment(savedInstanceState, "notificationsFragment");
profileFragment=(ProfileFragment) getChildFragmentManager().getFragment(savedInstanceState, "profileFragment");
currentTab=savedInstanceState.getInt("selectedTab");
tabBar.selectTab(currentTab);
Fragment current=fragmentForTab(currentTab);
getChildFragmentManager().beginTransaction()
.hide(homeTimelineFragment)
.hide(searchFragment)
.hide(homeTabFragment)
.hide(discoverFragment)
.hide(notificationsFragment)
.hide(profileFragment)
.show(current)
@@ -203,17 +236,17 @@ public class HomeFragment extends AppKitFragment implements OnBackPressedListene
super.onApplyWindowInsets(insets.replaceSystemWindowInsets(insets.getSystemWindowInsetLeft(), 0, insets.getSystemWindowInsetRight(), insets.getSystemWindowInsetBottom()));
}
WindowInsets topOnlyInsets=insets.replaceSystemWindowInsets(0, insets.getSystemWindowInsetTop(), 0, 0);
homeTimelineFragment.onApplyWindowInsets(topOnlyInsets);
searchFragment.onApplyWindowInsets(topOnlyInsets);
homeTabFragment.onApplyWindowInsets(topOnlyInsets);
discoverFragment.onApplyWindowInsets(topOnlyInsets);
notificationsFragment.onApplyWindowInsets(topOnlyInsets);
profileFragment.onApplyWindowInsets(topOnlyInsets);
}
private Fragment fragmentForTab(@IdRes int tab){
if(tab==R.id.tab_home){
return homeTimelineFragment;
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){
@@ -232,12 +265,15 @@ public class HomeFragment extends AppKitFragment implements OnBackPressedListene
private void onTabSelected(@IdRes int tab){
Fragment newFragment=fragmentForTab(tab);
if(tab==currentTab){
if(newFragment instanceof ScrollableToTop scrollable)
if (tab == R.id.tab_search && GlobalUserPreferences.doubleTapToSearch)
discoverFragment.openSearch();
else if(newFragment instanceof ScrollableToTop scrollable)
scrollable.scrollToTop();
return;
}
getChildFragmentManager().beginTransaction().hide(fragmentForTab(currentTab)).show(newFragment).commit();
maybeTriggerLoading(newFragment);
if (newFragment instanceof HasFab fabulous && !fabulous.isScrolling()) fabulous.showFab();
currentTab=tab;
((FragmentStackActivity)getActivity()).invalidateSystemBarColors(this);
}
@@ -248,10 +284,14 @@ public class HomeFragment extends AppKitFragment implements OnBackPressedListene
lf.loadData();
}else if(newFragment instanceof DiscoverFragment){
((DiscoverFragment) newFragment).loadData();
}
if(newFragment instanceof NotificationsListFragment){
}else if(newFragment instanceof NotificationsFragment){
((NotificationsFragment) newFragment).loadData();
NotificationManager nm=getActivity().getSystemService(NotificationManager.class);
nm.cancel(accountID, PushNotificationReceiver.NOTIFICATION_ID);
for (StatusBarNotification notification : nm.getActiveNotifications()) {
if (accountID.equals(notification.getTag())) {
nm.cancel(accountID, notification.getId());
}
}
}
}
@@ -259,12 +299,20 @@ public class HomeFragment extends AppKitFragment implements OnBackPressedListene
if(tab==R.id.tab_profile){
ArrayList<String> options=new ArrayList<>();
for(AccountSession session:AccountSessionManager.getInstance().getLoggedInAccounts()){
options.add(session.self.displayName+"\n("+session.self.username+"@"+session.domain+")");
options.add(session.self.getDisplayName()+"\n("+session.self.username+"@"+session.domain+")");
}
new AccountSwitcherSheet(getActivity(), this).show();
return true;
}
if(tab==R.id.tab_home && BuildConfig.DEBUG){
if(tab==R.id.tab_search){
if(currentTab!=R.id.tab_search){
onTabSelected(R.id.tab_search);
tabBar.selectTab(R.id.tab_search);
}
discoverFragment.openSearch();
return true;
}
if(tab==R.id.tab_home){
Bundle args=new Bundle();
args.putString("account", accountID);
Nav.go(getActivity(), OnboardingFollowSuggestionsFragment.class, args);
@@ -275,20 +323,26 @@ public class HomeFragment extends AppKitFragment implements OnBackPressedListene
@Override
public boolean onBackPressed(){
if(currentTab==R.id.tab_profile)
return profileFragment.onBackPressed();
if (profileFragment.onBackPressed()) return true;
if(currentTab==R.id.tab_search)
return searchFragment.onBackPressed();
return false;
if (discoverFragment.onBackPressed()) return true;
if (currentTab!=R.id.tab_home) {
tabBar.selectTab(R.id.tab_home);
onTabSelected(R.id.tab_home);
return true;
} else {
return homeTabFragment.onBackPressed();
}
}
@Override
public void onSaveInstanceState(Bundle outState){
super.onSaveInstanceState(outState);
outState.putInt("selectedTab", currentTab);
getChildFragmentManager().putFragment(outState, "homeTimelineFragment", homeTimelineFragment);
getChildFragmentManager().putFragment(outState, "searchFragment", searchFragment);
getChildFragmentManager().putFragment(outState, "notificationsFragment", notificationsFragment);
getChildFragmentManager().putFragment(outState, "profileFragment", profileFragment);
if (homeTabFragment.isAdded()) getChildFragmentManager().putFragment(outState, "homeTabFragment", homeTabFragment);
if (discoverFragment.isAdded()) getChildFragmentManager().putFragment(outState, "searchFragment", discoverFragment);
if (notificationsFragment.isAdded()) getChildFragmentManager().putFragment(outState, "notificationsFragment", notificationsFragment);
if (profileFragment.isAdded()) getChildFragmentManager().putFragment(outState, "profileFragment", profileFragment);
}
@Override
@@ -297,7 +351,7 @@ public class HomeFragment extends AppKitFragment implements OnBackPressedListene
reloadNotificationsForUnreadCount();
}
private void reloadNotificationsForUnreadCount(){
public void reloadNotificationsForUnreadCount(){
List<Notification>[] notifications=new List[]{null};
String[] marker={null};
@@ -308,7 +362,7 @@ public class HomeFragment extends AppKitFragment implements OnBackPressedListene
}
});
AccountSessionManager.get(accountID).getCacheController().getNotifications(null, 40, false, true, new Callback<>(){
AccountSessionManager.get(accountID).getCacheController().getNotifications(null, 40, false, false, true, new Callback<>(){
@Override
public void onSuccess(PaginatedResponse<List<Notification>> result){
notifications[0]=result.items;
@@ -324,9 +378,9 @@ public class HomeFragment extends AppKitFragment implements OnBackPressedListene
@SuppressLint("DefaultLocale")
private void updateUnreadCount(List<Notification> notifications, String marker){
if(notifications.isEmpty() || ObjectIdComparator.INSTANCE.compare(notifications.get(0).id, marker)<=0){
notificationsBadge.setVisibility(View.GONE);
V.setVisibilityAnimated(notificationsBadge, View.GONE);
}else{
notificationsBadge.setVisibility(View.VISIBLE);
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{
@@ -346,16 +400,28 @@ public class HomeFragment extends AppKitFragment implements OnBackPressedListene
if(!ev.accountID.equals(accountID))
return;
if(ev.clearUnread)
notificationsBadge.setVisibility(View.GONE);
V.setVisibilityAnimated(notificationsBadge, View.GONE);
}
@Subscribe
public void onStatusDisplaySettingsChanged(StatusDisplaySettingsChangedEvent ev){
if(!ev.accountID.equals(accountID))
return;
if(homeTimelineFragment.loaded)
if(homeTabFragment.getCurrentFragment() instanceof LoaderFragment lf && lf.loaded
&& lf instanceof BaseStatusListFragment<?> homeTimelineFragment)
homeTimelineFragment.rebuildAllDisplayItems();
if(notificationsFragment.loaded)
notificationsFragment.rebuildAllDisplayItems();
if(notificationsFragment.getCurrentFragment() instanceof LoaderFragment lf && lf.loaded
&& lf instanceof BaseStatusListFragment<?> l)
l.rebuildAllDisplayItems();
}
@Override
public String getAccountID() {
return accountID;
}
@Override
public void onProvideAssistContent(AssistContent assistContent) {
callFragmentToProvideAssistContent(fragmentForTab(currentTab), assistContent);
}
}

View File

@@ -0,0 +1,777 @@
package org.joinmastodon.android.fragments;
import static org.joinmastodon.android.GlobalUserPreferences.reduceMotion;
import android.animation.Animator;
import android.animation.AnimatorListenerAdapter;
import android.animation.AnimatorSet;
import android.animation.ObjectAnimator;
import android.annotation.SuppressLint;
import android.app.Activity;
import android.app.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;
import android.view.Menu;
import android.view.MenuInflater;
import android.view.MenuItem;
import android.view.SubMenu;
import android.view.View;
import android.view.ViewGroup;
import android.view.ViewParent;
import android.view.ViewTreeObserver;
import android.widget.Button;
import android.widget.FrameLayout;
import android.widget.ImageButton;
import android.widget.ImageView;
import android.widget.PopupMenu;
import android.widget.TextView;
import android.widget.Toolbar;
import androidx.annotation.NonNull;
import androidx.recyclerview.widget.RecyclerView;
import androidx.viewpager2.widget.ViewPager2;
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.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;
import org.joinmastodon.android.events.SelfUpdateStateChangedEvent;
import org.joinmastodon.android.fragments.settings.SettingsMainFragment;
import org.joinmastodon.android.model.Announcement;
import org.joinmastodon.android.model.Hashtag;
import org.joinmastodon.android.model.HeaderPaginationList;
import org.joinmastodon.android.model.ListTimeline;
import org.joinmastodon.android.model.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;
import java.util.Optional;
import java.util.function.Predicate;
import java.util.function.Supplier;
import me.grishka.appkit.Nav;
import me.grishka.appkit.api.Callback;
import me.grishka.appkit.api.ErrorResponse;
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, HasElevationOnScrollListener {
private static final int ANNOUNCEMENTS_RESULT = 654;
private String accountID;
private MenuItem announcements, announcementsAction, settings, settingsAction;
// private ImageView toolbarLogo;
private Button toolbarShowNewPostsBtn;
private boolean newPostsBtnShown;
private AnimatorSet currentNewPostsAnim;
private ViewPager2 pager;
private View switcher;
private FrameLayout toolbarFrame;
private ImageView timelineIcon;
private ImageView collapsedChevron;
private TextView timelineTitle;
private PopupMenu switcherPopup;
private final Map<Integer, ListTimeline> listItems = new HashMap<>();
private final Map<Integer, Hashtag> hashtagsItems = new HashMap<>();
private List<TimelineDefinition> timelinesList;
private int count;
private Fragment[] fragments;
private FrameLayout[] tabViews;
private TimelineDefinition[] timelines;
private final Map<Integer, TimelineDefinition> timelinesByMenuItem = new HashMap<>();
private SubMenu hashtagsMenu, listsMenu;
private PopupMenu overflowPopup;
private View overflowActionView = null;
private boolean announcementsBadged, settingsBadged;
private ImageButton fab;
private ElevationOnScrollListener elevationOnScrollListener;
@Override
public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
E.register(this);
accountID = getArguments().getString("account");
timelinesList=AccountSessionManager.get(accountID).getLocalPreferences().timelines;
assert timelinesList!=null;
if(timelinesList.isEmpty()) timelinesList=List.of(TimelineDefinition.HOME_TIMELINE);
count=timelinesList.size();
fragments=new Fragment[count];
tabViews=new FrameLayout[count];
timelines=new TimelineDefinition[count];
if(GlobalUserPreferences.toolbarMarquee){
setTitleMarqueeEnabled(false);
setSubtitleMarqueeEnabled(false);
}
}
@Override
public void onAttach(Activity activity) {
super.onAttach(activity);
setHasOptionsMenu(true);
}
@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);
fab.setOnLongClickListener(this::onFabLongClick);
pager = new ViewPager2(getContext());
toolbarFrame = (FrameLayout) LayoutInflater.from(getContext()).inflate(R.layout.home_toolbar, getToolbar(), false);
if (fragments[0] == null) {
Bundle args = new Bundle();
args.putString("account", accountID);
args.putBoolean("__is_tab", true);
args.putBoolean("__disable_fab", true);
args.putBoolean("onlyPosts", true);
for (int i=0; i < timelinesList.size(); i++) {
TimelineDefinition tl = timelinesList.get(i);
fragments[i] = tl.getFragment();
timelines[i] = tl;
}
FragmentTransaction transaction = getChildFragmentManager().beginTransaction();
for (int i = 0; i < count; i++) {
fragments[i].setArguments(timelines[i].populateArguments(new Bundle(args)));
FrameLayout tabView = new FrameLayout(getActivity());
tabView.setLayoutParams(new ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT));
tabView.setVisibility(View.GONE);
tabView.setId(i + 1);
transaction.add(i + 1, fragments[i]);
view.addView(tabView);
tabViews[i] = tabView;
}
transaction.commit();
}
view.addView(pager, new ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT));
overflowActionView = UiUtils.makeOverflowActionView(getContext());
overflowPopup = new PopupMenu(getContext(), overflowActionView);
overflowPopup.setOnMenuItemClickListener(this::onOptionsItemSelected);
overflowActionView.setOnClickListener(l -> overflowPopup.show());
overflowActionView.setOnTouchListener(overflowPopup.getDragToOpenListener());
return rootView;
}
@SuppressLint("ClickableViewAccessibility")
@Override
public void onViewCreated(View view, Bundle savedInstanceState){
super.onViewCreated(view, savedInstanceState);
timelineIcon = toolbarFrame.findViewById(R.id.timeline_icon);
timelineTitle = toolbarFrame.findViewById(R.id.timeline_title);
collapsedChevron = toolbarFrame.findViewById(R.id.collapsed_chevron);
switcher = toolbarFrame.findViewById(R.id.switcher_btn);
switcherPopup = new PopupMenu(getContext(), switcher);
switcherPopup.setOnMenuItemClickListener(this::onSwitcherItemSelected);
UiUtils.enablePopupMenuIcons(getContext(), switcherPopup);
switcher.setOnClickListener(v->switcherPopup.show());
switcher.setOnTouchListener(switcherPopup.getDragToOpenListener());
updateSwitcherMenu();
UiUtils.reduceSwipeSensitivity(pager);
pager.setUserInputEnabled(!GlobalUserPreferences.disableSwipe);
pager.setAdapter(new HomePagerAdapter());
pager.registerOnPageChangeCallback(new ViewPager2.OnPageChangeCallback() {
@Override
public void onPageSelected(int position){
if (!reduceMotion) {
// setting this here because page transformer appears to fire too late so the
// animation can appear bumpy, especially when navigating to a further-away tab
switcher.setScaleY(0.85f);
switcher.setScaleX(0.85f);
switcher.setAlpha(0.65f);
}
updateSwitcherIcon(position);
if (!timelines[position].equals(TimelineDefinition.HOME_TIMELINE)) hideNewPostsButton();
if (fragments[position] instanceof BaseRecyclerFragment<?> page){
if(!page.loaded && !page.isDataLoading()) page.loadData();
}
}
});
if (!reduceMotion) {
pager.setPageTransformer((v, pos) -> {
if (reduceMotion || tabViews[pager.getCurrentItem()] != v) return;
float scaleFactor = Math.max(0.85f, 1 - Math.abs(pos) * 0.06f);
switcher.setScaleY(scaleFactor);
switcher.setScaleX(scaleFactor);
switcher.setAlpha(Math.max(0.65f, 1 - Math.abs(pos)));
});
}
updateToolbarLogo();
ViewTreeObserver vto = getToolbar().getViewTreeObserver();
if (vto.isAlive()) {
vto.addOnGlobalLayoutListener(()->{
Toolbar t=getToolbar();
if(t==null) return;
int toolbarWidth=t.getWidth();
if(toolbarWidth==0) return;
int toolbarFrameWidth=toolbarFrame.getWidth();
int actionsWidth=toolbarWidth-toolbarFrameWidth;
// margin (4) + padding (12) + icon (24) + margin (8) + chevron (16) + padding (12)
int switcherWidth=V.dp(76);
FrameLayout parent=((FrameLayout) toolbarShowNewPostsBtn.getParent());
if(actionsWidth==parent.getPaddingStart()) return;
int paddingMax=Math.max(actionsWidth, switcherWidth);
int paddingEnd=(Math.max(0, switcherWidth-actionsWidth));
// toolbar frame goes from screen edge to beginning of right-aligned option buttons.
// centering button by applying the same space on the left
parent.setPaddingRelative(paddingMax, 0, paddingEnd, 0);
toolbarShowNewPostsBtn.setMaxWidth(toolbarWidth-paddingMax*2);
switcher.setPivotX(V.dp(28)); // padding + half of icon
switcher.setPivotY(switcher.getHeight() / 2f);
});
}
elevationOnScrollListener = new ElevationOnScrollListener((FragmentRootLinearLayout) view, getToolbar());
if(GithubSelfUpdater.needSelfUpdating()){
updateUpdateState(GithubSelfUpdater.getInstance().getState());
}
new GetLists().setCallback(new Callback<>() {
@Override
public void onSuccess(List<ListTimeline> lists) {
updateList(lists, listItems);
}
@Override
public void onError(ErrorResponse error) {
error.showToast(getContext());
}
}).exec(accountID);
new GetFollowedHashtags().setCallback(new Callback<>() {
@Override
public void onSuccess(HeaderPaginationList<Hashtag> hashtags) {
updateList(hashtags, hashtagsItems);
}
@Override
public void onError(ErrorResponse error) {
error.showToast(getContext());
}
}).exec(accountID);
new GetAnnouncements(false).setCallback(new Callback<>() {
@Override
public void onSuccess(List<Announcement> result) {
if(getActivity()==null) return;
if (result.stream().anyMatch(a -> !a.read)) {
announcementsBadged = true;
announcements.setVisible(false);
announcementsAction.setVisible(true);
}
}
@Override
public void onError(ErrorResponse error) {
error.showToast(getActivity());
}
}).exec(accountID);
}
public ElevationOnScrollListener getElevationOnScrollListener() {
return elevationOnScrollListener;
}
private void onFabClick(View v){
if (fragments[pager.getCurrentItem()] instanceof BaseStatusListFragment<?> l) {
l.onFabClick(v);
}
}
private boolean onFabLongClick(View v) {
if (fragments[pager.getCurrentItem()] instanceof BaseStatusListFragment<?> l) {
return l.onFabLongClick(v);
} else {
return false;
}
}
private void addListsToOverflowMenu() {
Context ctx = getContext();
listsMenu.clear();
listsMenu.getItem().setVisible(listItems.size() > 0);
UiUtils.insetPopupMenuIcon(ctx, UiUtils.makeBackItem(listsMenu));
listItems.forEach((id, list) -> {
MenuItem item = listsMenu.add(Menu.NONE, id, Menu.NONE, list.title);
item.setIcon(R.drawable.ic_fluent_people_24_regular);
UiUtils.insetPopupMenuIcon(ctx, item);
});
}
private void addHashtagsToOverflowMenu() {
Context ctx = getContext();
hashtagsMenu.clear();
hashtagsMenu.getItem().setVisible(hashtagsItems.size() > 0);
UiUtils.insetPopupMenuIcon(ctx, UiUtils.makeBackItem(hashtagsMenu));
hashtagsItems.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(){
Toolbar toolbar = getToolbar();
ViewParent parentView = toolbarFrame.getParent();
if (parentView == toolbar) return;
if (parentView instanceof Toolbar parentToolbar) parentToolbar.removeView(toolbarFrame);
toolbar.addView(toolbarFrame, ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT);
toolbar.setOnClickListener(v->scrollToTop());
toolbar.setNavigationContentDescription(R.string.back);
toolbar.setContentInsetsAbsolute(0, toolbar.getContentInsetRight());
updateSwitcherIcon(pager.getCurrentItem());
toolbarShowNewPostsBtn=toolbarFrame.findViewById(R.id.show_new_posts_btn);
toolbarShowNewPostsBtn.setCompoundDrawableTintList(toolbarShowNewPostsBtn.getTextColors());
if(Build.VERSION.SDK_INT<Build.VERSION_CODES.N) UiUtils.fixCompoundDrawableTintOnAndroid6(toolbarShowNewPostsBtn);
toolbarShowNewPostsBtn.setOnClickListener(this::onNewPostsBtnClick);
if(newPostsBtnShown){
toolbarShowNewPostsBtn.setVisibility(View.VISIBLE);
collapsedChevron.setVisibility(View.VISIBLE);
collapsedChevron.setAlpha(1f);
timelineTitle.setVisibility(View.GONE);
timelineTitle.setAlpha(0f);
}else{
toolbarShowNewPostsBtn.setVisibility(View.INVISIBLE);
toolbarShowNewPostsBtn.setAlpha(0f);
collapsedChevron.setVisibility(View.GONE);
collapsedChevron.setAlpha(0f);
toolbarShowNewPostsBtn.setScaleX(.8f);
toolbarShowNewPostsBtn.setScaleY(.8f);
timelineTitle.setVisibility(View.VISIBLE);
}
}
private void updateOverflowMenu() {
if(getActivity()==null) return;
Menu m = overflowPopup.getMenu();
m.clear();
overflowPopup.inflate(R.menu.home_overflow);
announcements = m.findItem(R.id.announcements);
settings = m.findItem(R.id.settings);
hashtagsMenu = m.findItem(R.id.hashtags).getSubMenu();
listsMenu = m.findItem(R.id.lists).getSubMenu();
announcements.setVisible(!announcementsBadged);
announcementsAction.setVisible(announcementsBadged);
settings.setVisible(!settingsBadged);
settingsAction.setVisible(settingsBadged);
UiUtils.enablePopupMenuIcons(getContext(), overflowPopup);
addListsToOverflowMenu();
addHashtagsToOverflowMenu();
if(Build.VERSION.SDK_INT>=Build.VERSION_CODES.P && !UiUtils.isEMUI())
m.setGroupDividerEnabled(true);
}
@Override
public void onCreateOptionsMenu(Menu menu, MenuInflater inflater){
inflater.inflate(R.menu.home, menu);
menu.findItem(R.id.overflow).setActionView(overflowActionView);
announcementsAction = menu.findItem(R.id.announcements_action);
settingsAction = menu.findItem(R.id.settings_action);
updateOverflowMenu();
}
private <T> void updateList(List<T> addItems, Map<Integer, T> items) {
if (addItems.size() == 0 || getActivity() == null) return;
for (int i = 0; i < addItems.size(); i++) items.put(View.generateViewId(), addItems.get(i));
updateOverflowMenu();
}
private void updateSwitcherMenu() {
Menu switcherMenu = switcherPopup.getMenu();
switcherMenu.clear();
timelinesByMenuItem.clear();
for (TimelineDefinition tl : timelines) {
int menuItemId = View.generateViewId();
timelinesByMenuItem.put(menuItemId, tl);
MenuItem item = switcherMenu.add(0, menuItemId, 0, tl.getTitle(getContext()));
item.setIcon(tl.getIcon().iconRes);
}
UiUtils.enablePopupMenuIcons(getContext(), switcherPopup);
}
private boolean onSwitcherItemSelected(MenuItem item) {
int id = item.getItemId();
Bundle args = new Bundle();
args.putString("account", accountID);
if (id == R.id.menu_back) {
switcher.post(() -> switcherPopup.show());
return true;
}
TimelineDefinition tl = timelinesByMenuItem.get(id);
if (tl != null) {
for (int i = 0; i < timelines.length; i++) {
if (timelines[i] == tl) {
navigateTo(i);
return true;
}
}
}
return false;
}
private void navigateTo(int i) {
navigateTo(i, !reduceMotion);
}
private void navigateTo(int i, boolean smooth) {
pager.setCurrentItem(i, smooth);
updateSwitcherIcon(i);
}
@Override
public void showFab() {
if (fragments[pager.getCurrentItem()] instanceof BaseStatusListFragment<?> l) l.showFab();
}
@Override
public void hideFab() {
if (fragments[pager.getCurrentItem()] instanceof BaseStatusListFragment<?> l) l.hideFab();
}
@Override
public boolean isScrolling() {
return (fragments[pager.getCurrentItem()] instanceof HasFab fabulous)
&& 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
public boolean onOptionsItemSelected(MenuItem item){
Bundle args=new Bundle();
args.putString("account", accountID);
int id = item.getItemId();
ListTimeline list;
Hashtag hashtag;
if (item.getItemId() == R.id.menu_back) {
getToolbar().post(() -> overflowPopup.show());
return true;
} else if (id == R.id.settings || id == R.id.settings_action) {
Nav.go(getActivity(), SettingsMainFragment.class, args);
} else if (id == R.id.announcements || id == R.id.announcements_action) {
Nav.goForResult(getActivity(), AnnouncementsFragment.class, args, ANNOUNCEMENTS_RESULT, this);
} else if (id == R.id.edit_timelines) {
Nav.go(getActivity(), EditTimelinesFragment.class, args);
} else if ((list = listItems.get(id)) != null) {
args.putString("listID", list.id);
args.putString("listTitle", list.title);
args.putBoolean("listIsExclusive", list.exclusive);
if (list.repliesPolicy != null) args.putInt("repliesPolicy", list.repliesPolicy.ordinal());
Nav.go(getActivity(), ListTimelineFragment.class, args);
} else if ((hashtag = hashtagsItems.get(id)) != null) {
UiUtils.openHashtagTimeline(getContext(), accountID, hashtag);
}
return true;
}
@Override
public void scrollToTop(){
if (((IsOnTop) fragments[pager.getCurrentItem()]).isOnTop() &&
GlobalUserPreferences.doubleTapToSwipe && !newPostsBtnShown) {
int nextPage = (pager.getCurrentItem() + 1) % count;
navigateTo(nextPage);
return;
}
((ScrollableToTop) fragments[pager.getCurrentItem()]).scrollToTop();
}
public void hideNewPostsButton(){
if(!newPostsBtnShown)
return;
newPostsBtnShown=false;
if(currentNewPostsAnim!=null){
currentNewPostsAnim.cancel();
}
timelineTitle.setVisibility(View.VISIBLE);
AnimatorSet set=new AnimatorSet();
set.playTogether(
ObjectAnimator.ofFloat(timelineTitle, View.ALPHA, 1f),
ObjectAnimator.ofFloat(timelineTitle, View.SCALE_X, 1f),
ObjectAnimator.ofFloat(timelineTitle, View.SCALE_Y, 1f),
ObjectAnimator.ofFloat(toolbarShowNewPostsBtn, View.ALPHA, 0f),
ObjectAnimator.ofFloat(toolbarShowNewPostsBtn, View.SCALE_X, .8f),
ObjectAnimator.ofFloat(toolbarShowNewPostsBtn, View.SCALE_Y, .8f),
ObjectAnimator.ofFloat(collapsedChevron, View.ALPHA, 0f)
);
set.setDuration(reduceMotion ? 0 : 300);
set.setInterpolator(CubicBezierInterpolator.DEFAULT);
set.addListener(new AnimatorListenerAdapter(){
@Override
public void onAnimationEnd(Animator animation){
toolbarShowNewPostsBtn.setVisibility(View.INVISIBLE);
collapsedChevron.setVisibility(View.GONE);
currentNewPostsAnim=null;
}
});
currentNewPostsAnim=set;
set.start();
}
public void showNewPostsButton(){
if(newPostsBtnShown || pager == null || !timelines[pager.getCurrentItem()].equals(TimelineDefinition.HOME_TIMELINE))
return;
newPostsBtnShown=true;
if(currentNewPostsAnim!=null){
currentNewPostsAnim.cancel();
}
toolbarShowNewPostsBtn.setVisibility(View.VISIBLE);
collapsedChevron.setVisibility(View.VISIBLE);
AnimatorSet set=new AnimatorSet();
set.playTogether(
ObjectAnimator.ofFloat(timelineTitle, View.ALPHA, 0f),
ObjectAnimator.ofFloat(timelineTitle, View.SCALE_X, .8f),
ObjectAnimator.ofFloat(timelineTitle, View.SCALE_Y, .8f),
ObjectAnimator.ofFloat(toolbarShowNewPostsBtn, View.ALPHA, 1f),
ObjectAnimator.ofFloat(toolbarShowNewPostsBtn, View.SCALE_X, 1f),
ObjectAnimator.ofFloat(toolbarShowNewPostsBtn, View.SCALE_Y, 1f),
ObjectAnimator.ofFloat(collapsedChevron, View.ALPHA, 1f)
);
set.setDuration(reduceMotion ? 0 : 300);
set.setInterpolator(CubicBezierInterpolator.DEFAULT);
set.addListener(new AnimatorListenerAdapter(){
@Override
public void onAnimationEnd(Animator animation){
timelineTitle.setVisibility(View.GONE);
currentNewPostsAnim=null;
}
});
currentNewPostsAnim=set;
set.start();
}
public boolean isNewPostsBtnShown() {
return newPostsBtnShown;
}
private void onNewPostsBtnClick(View view) {
if(newPostsBtnShown){
scrollToTop();
hideNewPostsButton();
}
}
@Override
public void onFragmentResult(int reqCode, boolean success, Bundle result){
if (reqCode == ANNOUNCEMENTS_RESULT && success) {
announcementsBadged = false;
announcements.setVisible(true);
announcementsAction.setVisible(false);
}
}
private void updateUpdateState(GithubSelfUpdater.UpdateState state){
if(state!=GithubSelfUpdater.UpdateState.NO_UPDATE && state!=GithubSelfUpdater.UpdateState.CHECKING) {
settingsBadged = true;
settingsAction.setVisible(true);
settings.setVisible(false);
}
}
@Subscribe
public void onSelfUpdateStateChanged(SelfUpdateStateChangedEvent ev){
updateUpdateState(ev.state);
}
@Override
public boolean onBackPressed(){
if(pager.getCurrentItem() > 0){
navigateTo(0);
return true;
}
return false;
}
@Override
public void onDestroyView(){
super.onDestroyView();
if (overflowPopup != null) {
overflowPopup.dismiss();
overflowPopup = null;
}
if (switcherPopup != null) {
switcherPopup.dismiss();
switcherPopup = null;
}
if(GithubSelfUpdater.needSelfUpdating()){
E.unregister(this);
}
}
@Override
protected void onShown() {
super.onShown();
Object timelines = AccountSessionManager.get(accountID).getLocalPreferences().timelines;
if (timelines != null && timelinesList!= timelines) UiUtils.restartApp();
}
@Override
public void onViewStateRestored(Bundle savedInstanceState) {
super.onViewStateRestored(savedInstanceState);
if (savedInstanceState == null) return;
navigateTo(savedInstanceState.getInt("selectedTab"), false);
}
@Override
public void onSaveInstanceState(Bundle outState) {
super.onSaveInstanceState(outState);
outState.putInt("selectedTab", pager.getCurrentItem());
}
@Subscribe
public void onHashtagUpdatedEvent(HashtagUpdatedEvent event) {
handleListEvent(hashtagsItems, h -> h.name.equalsIgnoreCase(event.name), event.following, () -> {
Hashtag hashtag = new Hashtag();
hashtag.name = event.name;
hashtag.following = true;
return hashtag;
});
}
@Subscribe
public void onListDeletedEvent(ListDeletedEvent event) {
handleListEvent(listItems, l -> l.id.equals(event.id), false, null);
}
@Subscribe
public void onListUpdatedCreatedEvent(ListUpdatedCreatedEvent event) {
handleListEvent(listItems, l -> l.id.equals(event.id), true, () -> {
ListTimeline list = new ListTimeline();
list.id = event.id;
list.title = event.title;
list.repliesPolicy = event.repliesPolicy;
return list;
});
}
private <T> void handleListEvent(
Map<Integer, T> existingThings,
Predicate<T> matchExisting,
boolean shouldBeInList,
Supplier<T> makeNewThing
) {
Optional<Map.Entry<Integer, T>> existingThing = existingThings.entrySet().stream()
.filter(e -> matchExisting.test(e.getValue())).findFirst();
if (shouldBeInList) {
existingThings.put(existingThing.isPresent()
? existingThing.get().getKey() : View.generateViewId(), makeNewThing.get());
updateOverflowMenu();
} else if (existingThing.isPresent() && !shouldBeInList) {
existingThings.remove(existingThing.get().getKey());
updateOverflowMenu();
}
}
public Collection<Hashtag> getHashtags() {
return hashtagsItems.values();
}
public Fragment getCurrentFragment() {
return fragments[pager.getCurrentItem()];
}
public ImageButton getFab() {
return fab;
}
@Override
public void onProvideAssistContent(AssistContent assistContent) {
callFragmentToProvideAssistContent(fragments[pager.getCurrentItem()], assistContent);
}
private class HomePagerAdapter extends RecyclerView.Adapter<SimpleViewHolder> {
@NonNull
@Override
public SimpleViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
FrameLayout tabView = tabViews[viewType % getItemCount()];
ViewGroup tabParent = (ViewGroup) tabView.getParent();
if (tabParent != null) tabParent.removeView(tabView);
tabView.setVisibility(View.VISIBLE);
return new SimpleViewHolder(tabView);
}
@Override
public void onBindViewHolder(@NonNull SimpleViewHolder holder, int position){}
@Override
public int getItemCount(){
return count;
}
@Override
public int getItemViewType(int position){
return position;
}
}
}

View File

@@ -1,270 +1,84 @@
package org.joinmastodon.android.fragments;
import android.animation.Animator;
import android.animation.AnimatorListenerAdapter;
import android.animation.AnimatorSet;
import android.animation.ObjectAnimator;
import android.app.Activity;
import android.content.res.Configuration;
import android.net.Uri;
import android.os.Bundle;
import android.text.TextUtils;
import android.view.Gravity;
import android.view.Menu;
import android.view.MenuInflater;
import android.view.MenuItem;
import android.view.View;
import android.view.ViewGroup;
import android.view.accessibility.AccessibilityNodeInfo;
import android.view.animation.AnimationUtils;
import android.widget.Button;
import android.widget.FrameLayout;
import android.widget.ImageButton;
import android.widget.ImageView;
import android.widget.LinearLayout;
import android.widget.TextView;
import android.widget.Toolbar;
import com.squareup.otto.Subscribe;
import androidx.annotation.NonNull;
import androidx.recyclerview.widget.RecyclerView;
import org.joinmastodon.android.E;
import org.joinmastodon.android.R;
import org.joinmastodon.android.api.MastodonAPIRequest;
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.requests.timelines.GetListTimeline;
import org.joinmastodon.android.api.requests.timelines.GetPublicTimeline;
import org.joinmastodon.android.api.session.AccountSessionManager;
import org.joinmastodon.android.events.SelfUpdateStateChangedEvent;
import org.joinmastodon.android.fragments.settings.SettingsMainFragment;
import org.joinmastodon.android.model.CacheablePaginatedResponse;
import org.joinmastodon.android.model.FilterContext;
import org.joinmastodon.android.model.FollowList;
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.ui.utils.DiscoverInfoBannerHelper;
import org.joinmastodon.android.ui.viewcontrollers.HomeTimelineMenuController;
import org.joinmastodon.android.ui.viewcontrollers.ToolbarDropdownMenuController;
import org.joinmastodon.android.ui.views.FixedAspectRatioImageView;
import org.joinmastodon.android.updater.GithubSelfUpdater;
import org.parceler.Parcels;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashSet;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.stream.Collectors;
import androidx.annotation.NonNull;
import androidx.recyclerview.widget.RecyclerView;
import me.grishka.appkit.Nav;
import me.grishka.appkit.api.Callback;
import me.grishka.appkit.api.ErrorResponse;
import me.grishka.appkit.api.SimpleCallback;
import me.grishka.appkit.utils.CubicBezierInterpolator;
import me.grishka.appkit.utils.MergeRecyclerAdapter;
import me.grishka.appkit.utils.V;
public class HomeTimelineFragment extends StatusListFragment implements ToolbarDropdownMenuController.HostFragment{
private ImageButton fab;
private LinearLayout listsDropdown;
private FixedAspectRatioImageView listsDropdownArrow;
private TextView listsDropdownText;
private Button newPostsBtn;
private View newPostsBtnWrap;
private boolean newPostsBtnShown;
private AnimatorSet currentNewPostsAnim;
private ToolbarDropdownMenuController dropdownController;
private HomeTimelineMenuController dropdownMainMenuController;
private List<FollowList> lists=List.of();
private ListMode listMode=ListMode.FOLLOWING;
private FollowList currentList;
private MergeRecyclerAdapter mergeAdapter;
private DiscoverInfoBannerHelper localTimelineBannerHelper;
public class HomeTimelineFragment extends StatusListFragment {
private HomeTabFragment parent;
private String maxID;
private String lastSavedMarkerID;
public HomeTimelineFragment(){
setListLayoutId(R.layout.fragment_timeline);
}
@Override
public void onCreate(Bundle savedInstanceState){
super.onCreate(savedInstanceState);
localTimelineBannerHelper=new DiscoverInfoBannerHelper(DiscoverInfoBannerHelper.BannerType.LOCAL_TIMELINE, accountID);
protected boolean wantsComposeButton() {
return true;
}
@Override
public void onAttach(Activity activity){
super.onAttach(activity);
dropdownController=new ToolbarDropdownMenuController(this);
dropdownMainMenuController=new HomeTimelineMenuController(dropdownController, new HomeTimelineMenuController.Callback(){
@Override
public void onFollowingSelected(){
if(listMode==ListMode.FOLLOWING)
return;
listMode=ListMode.FOLLOWING;
reload();
}
@Override
public void onLocalSelected(){
if(listMode==ListMode.LOCAL)
return;
listMode=ListMode.LOCAL;
reload();
}
@Override
public List<FollowList> getLists(){
return lists;
}
@Override
public void onListSelected(FollowList list){
if(listMode==ListMode.LIST && currentList==list)
return;
listMode=ListMode.LIST;
currentList=list;
reload();
}
});
setHasOptionsMenu(true);
if (getParentFragment() instanceof HomeTabFragment home) parent = home;
loadData();
AccountSessionManager.get(accountID).getCacheController().getLists(new Callback<>(){
@Override
public void onSuccess(List<FollowList> result){
lists=result;
}
@Override
public void onError(ErrorResponse error){}
});
}
@Override
protected void doLoadData(int offset, int count){
switch(listMode){
case FOLLOWING -> {
AccountSessionManager.getInstance()
.getAccount(accountID).getCacheController()
.getHomeTimeline(offset>0 ? maxID : null, count, refreshing, new SimpleCallback<>(this){
@Override
public void onSuccess(CacheablePaginatedResponse<List<Status>> result){
if(getActivity()==null || listMode!=ListMode.FOLLOWING)
return;
if(refreshing)
list.scrollToPosition(0);
onDataLoaded(result.items, !result.items.isEmpty());
maxID=result.maxID;
if(result.isFromCache())
loadNewPosts();
}
@Override
public void onError(ErrorResponse error){
if(listMode!=ListMode.FOLLOWING)
return;
super.onError(error);
}
});
}
case LOCAL -> {
currentRequest=new GetPublicTimeline(true, false, offset>0 ? maxID : null, null, count, null)
.setCallback(new SimpleCallback<>(this){
@Override
public void onSuccess(List<Status> result){
if(refreshing)
list.scrollToPosition(0);
maxID=result.isEmpty() ? null : result.get(result.size()-1).id;
AccountSessionManager.get(accountID).filterStatuses(result, FilterContext.PUBLIC);
onDataLoaded(result, !result.isEmpty());
}
})
.exec(accountID);
}
case LIST -> {
currentRequest=new GetListTimeline(currentList.id, offset>0 ? maxID : null, null, count, null)
.setCallback(new SimpleCallback<>(this){
@Override
public void onSuccess(List<Status> result){
if(refreshing)
list.scrollToPosition(0);
maxID=result.isEmpty() ? null : result.get(result.size()-1).id;
AccountSessionManager.get(accountID).filterStatuses(result, FilterContext.HOME);
onDataLoaded(result, !result.isEmpty());
}
})
.exec(accountID);
}
}
AccountSessionManager.getInstance()
.getAccount(accountID).getCacheController()
.getHomeTimeline(offset>0 ? maxID : null, count, refreshing, new SimpleCallback<>(this){
@Override
public void onSuccess(CacheablePaginatedResponse<List<Status>> result){
if(getActivity()==null) return;
boolean empty=result.items.isEmpty();
maxID=result.maxID;
AccountSessionManager.get(accountID).filterStatuses(result.items, getFilterContext());
onDataLoaded(result.items, !empty);
if(result.isFromCache() && GlobalUserPreferences.loadNewPosts)
loadNewPosts();
}
});
}
@Override
public void onViewCreated(View view, Bundle savedInstanceState){
super.onViewCreated(view, savedInstanceState);
fab=view.findViewById(R.id.fab);
fab.setOnClickListener(this::onFabClick);
newPostsBtn=view.findViewById(R.id.new_posts_btn);
newPostsBtn.setOnClickListener(this::onNewPostsBtnClick);
newPostsBtnWrap=view.findViewById(R.id.new_posts_btn_wrap);
if(newPostsBtnShown){
newPostsBtnWrap.setVisibility(View.VISIBLE);
}else{
newPostsBtnWrap.setVisibility(View.GONE);
newPostsBtnWrap.setScaleX(0.9f);
newPostsBtnWrap.setScaleY(0.9f);
newPostsBtnWrap.setAlpha(0f);
newPostsBtnWrap.setTranslationY(V.dp(-56));
}
updateToolbarLogo();
list.addOnScrollListener(new RecyclerView.OnScrollListener(){
@Override
public void onScrolled(@NonNull RecyclerView recyclerView, int dx, int dy){
if(newPostsBtnShown && list.getChildAdapterPosition(list.getChildAt(0))<=getMainAdapterOffset()){
hideNewPostsButton();
if(parent!=null && parent.isNewPostsBtnShown() && list.getChildAdapterPosition(list.getChildAt(0))<=getMainAdapterOffset()){
parent.hideNewPostsButton();
}
}
});
if(GithubSelfUpdater.needSelfUpdating()){
E.register(this);
updateUpdateState(GithubSelfUpdater.getInstance().getState());
}
}
@Override
public void onCreateOptionsMenu(Menu menu, MenuInflater inflater){
inflater.inflate(R.menu.home, menu);
menu.findItem(R.id.edit_list).setVisible(listMode==ListMode.LIST);
GithubSelfUpdater.UpdateState state=GithubSelfUpdater.UpdateState.NO_UPDATE;
GithubSelfUpdater updater=GithubSelfUpdater.getInstance();
if(updater!=null)
state=updater.getState();
if(state!=GithubSelfUpdater.UpdateState.NO_UPDATE && state!=GithubSelfUpdater.UpdateState.CHECKING)
getToolbar().getMenu().findItem(R.id.settings).setIcon(R.drawable.ic_settings_updateready_24px);
}
@Override
public boolean onOptionsItemSelected(MenuItem item){
Bundle args=new Bundle();
args.putString("account", accountID);
int id=item.getItemId();
if(id==R.id.settings){
Nav.go(getActivity(), SettingsMainFragment.class, args);
}else if(id==R.id.edit_list){
args.putParcelable("list", Parcels.wrap(currentList));
Nav.go(getActivity(), EditListFragment.class, args);
}
return true;
}
@Override
public void onConfigurationChanged(Configuration newConfig){
super.onConfigurationChanged(newConfig);
updateToolbarLogo();
}
@Override
@@ -273,7 +87,7 @@ public class HomeTimelineFragment extends StatusListFragment implements ToolbarD
if(!getArguments().getBoolean("noAutoLoad")){
if(!loaded && !dataLoading){
loadData();
}else if(!dataLoading){
}else if(!dataLoading && GlobalUserPreferences.loadNewPosts){
loadNewPosts();
}
}
@@ -282,7 +96,7 @@ public class HomeTimelineFragment extends StatusListFragment implements ToolbarD
@Override
protected void onHidden(){
super.onHidden();
if(!data.isEmpty() && listMode==ListMode.FOLLOWING){
if(!data.isEmpty()){
String topPostID=displayItems.get(Math.max(0, list.getChildAdapterPosition(list.getChildAt(0))-getMainAdapterOffset())).parentID;
if(!topPostID.equals(lastSavedMarkerID)){
lastSavedMarkerID=topPostID;
@@ -303,44 +117,46 @@ public class HomeTimelineFragment extends StatusListFragment implements ToolbarD
}
public void onStatusCreated(Status status){
if(status.reblog!=null) return;
prependItems(Collections.singletonList(status), true);
}
private void onFabClick(View v){
Bundle args=new Bundle();
args.putString("account", accountID);
Nav.go(getActivity(), ComposeFragment.class, args);
}
private void loadNewPosts(){
dataLoading=true;
// we only care about the data that was actually retrieved from the timeline api since
// user-created statuses are probably in the wrong position
List<Status> dataFromTimeline=data.stream().filter(s->!s.fromStatusCreated).collect(Collectors.toList());
// The idea here is that we request the timeline such that if there are fewer than `limit` posts,
// 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";
boolean needCache=listMode==ListMode.FOLLOWING;
loadAdditionalPosts(null, null, 20, sinceID, new Callback<>(){
String sinceID=dataFromTimeline.size()>1 ? dataFromTimeline.get(1).id : "1";
currentRequest=new GetHomeTimeline(null, null, 20, sinceID, getLocalPrefs().timelineReplyVisibility)
.setCallback(new Callback<>(){
@Override
public void onSuccess(List<Status> result){
currentRequest=null;
dataLoading=false;
refreshDone();
if(result.isEmpty() || getActivity()==null)
return;
Status last=result.get(result.size()-1);
List<Status> toAdd;
if(!data.isEmpty() && last.id.equals(data.get(0).id)){ // This part intersects with the existing one
toAdd=result.subList(0, result.size()-1); // Remove the already known last post
if(!dataFromTimeline.isEmpty() && last.id.equals(dataFromTimeline.get(0).id)){ // This part intersects with the existing one
toAdd=new ArrayList<>(result.subList(0, result.size()-1)); // Remove the already known last post
}else{
result.get(result.size()-1).hasGapAfter=true;
last.hasGapAfter=last.id;
toAdd=result;
}
if(needCache)
AccountSessionManager.get(accountID).filterStatuses(toAdd, FilterContext.HOME);
if(!toAdd.isEmpty())
AccountSessionManager.getInstance().getAccount(accountID).getCacheController().putHomeTimeline(new ArrayList<>(toAdd), false);
// removing statuses that come up as duplicates (hopefully only posts and boosts that were locally created
// and thus were already prepended to the timeline earlier)
List<String> existingIds=data.stream().map(Status::getID).collect(Collectors.toList());
toAdd.removeIf(s->existingIds.contains(s.getID()));
AccountSessionManager.get(accountID).filterStatuses(toAdd, getFilterContext());
if(!toAdd.isEmpty()){
prependItems(toAdd, true);
showNewPostsButton();
if(needCache)
AccountSessionManager.getInstance().getAccount(accountID).getCacheController().putHomeTimeline(toAdd, false);
if(parent != null && GlobalUserPreferences.showNewPostsButton) parent.showNewPostsButton();
}
}
@@ -348,24 +164,37 @@ public class HomeTimelineFragment extends StatusListFragment implements ToolbarD
public void onError(ErrorResponse error){
currentRequest=null;
dataLoading=false;
refreshDone();
}
});
})
.exec(accountID);
if (parent.getParentFragment() instanceof HomeFragment homeFragment) {
homeFragment.reloadNotificationsForUnreadCount();
}
}
@Override
public void onGapClick(GapStatusDisplayItem.Holder item){
public void onGapClick(GapStatusDisplayItem.Holder item, boolean downwards){
if(dataLoading)
return;
item.getItem().loading=true;
V.setVisibilityAnimated(item.progress, View.VISIBLE);
V.setVisibilityAnimated(item.text, View.GONE);
GapStatusDisplayItem gap=item.getItem();
gap.loading=true;
dataLoading=true;
boolean needCache=listMode==ListMode.FOLLOWING;
loadAdditionalPosts(item.getItemID(), null, 20, null, new Callback<>(){
String maxID=null;
String minID=null;
if (downwards) {
maxID=item.getItem().getMaxID();
} else {
int gapPos=displayItems.indexOf(gap);
StatusDisplayItem nextItem=displayItems.get(gapPos + 1);
minID=nextItem.parentID;
}
currentRequest=new GetHomeTimeline(maxID, minID, 20, null, getLocalPrefs().timelineReplyVisibility)
.setCallback(new Callback<>(){
@Override
public void onSuccess(List<Status> result){
currentRequest=null;
dataLoading=false;
if(getActivity()==null)
@@ -373,62 +202,108 @@ public class HomeTimelineFragment extends StatusListFragment implements ToolbarD
int gapPos=displayItems.indexOf(gap);
if(gapPos==-1)
return;
AccountSessionManager.get(accountID).filterStatuses(result, getFilterContext());
if(result.isEmpty()){
displayItems.remove(gapPos);
adapter.notifyItemRemoved(getMainAdapterOffset()+gapPos);
Status gapStatus=getStatusByID(gap.parentID);
if(gapStatus!=null){
gapStatus.hasGapAfter=false;
if(needCache)
AccountSessionManager.getInstance().getAccount(accountID).getCacheController().putHomeTimeline(Collections.singletonList(gapStatus), false);
gapStatus.hasGapAfter=null;
AccountSessionManager.getInstance().getAccount(accountID).getCacheController().putHomeTimeline(Collections.singletonList(gapStatus), false);
}
}else{
Set<String> idsBelowGap=new HashSet<>();
boolean belowGap=false;
int gapPostIndex=0;
for(Status s:data){
if(belowGap){
idsBelowGap.add(s.id);
}else if(s.id.equals(gap.parentID)){
belowGap=true;
s.hasGapAfter=false;
if(needCache)
// TODO: refactor this code. it's too long. incomprehensible, even
if(downwards) {
Set<String> idsBelowGap=new HashSet<>();
boolean belowGap=false;
int gapPostIndex=0;
for(Status s:data){
if(belowGap){
idsBelowGap.add(s.id);
}else if(s.id.equals(gap.parentID)){
belowGap=true;
s.hasGapAfter=null;
AccountSessionManager.getInstance().getAccount(accountID).getCacheController().putHomeTimeline(Collections.singletonList(s), false);
}else{
gapPostIndex++;
}else{
gapPostIndex++;
}
}
int endIndex=0;
for(Status s:result){
endIndex++;
if(idsBelowGap.contains(s.id))
break;
}
if(endIndex==result.size()){
Status last=result.get(result.size()-1);
last.hasGapAfter=last.id;
}else{
result=result.subList(0, endIndex);
}
}
int endIndex=0;
for(Status s:result){
endIndex++;
if(idsBelowGap.contains(s.id))
break;
}
if(endIndex==result.size()){
result.get(result.size()-1).hasGapAfter=true;
}else{
result=result.subList(0, endIndex);
}
if(needCache)
AccountSessionManager.get(accountID).filterStatuses(result, FilterContext.HOME);
List<StatusDisplayItem> targetList=displayItems.subList(gapPos, gapPos+1);
targetList.clear();
List<Status> insertedPosts=data.subList(gapPostIndex+1, gapPostIndex+1);
for(Status s:result){
if(idsBelowGap.contains(s.id))
break;
targetList.addAll(buildDisplayItems(s));
insertedPosts.add(s);
}
if(targetList.isEmpty()){
// oops. We didn't add new posts, but at least we know there are none.
adapter.notifyItemRemoved(getMainAdapterOffset()+gapPos);
}else{
adapter.notifyItemChanged(getMainAdapterOffset()+gapPos);
adapter.notifyItemRangeInserted(getMainAdapterOffset()+gapPos+1, targetList.size()-1);
}
if(needCache)
List<StatusDisplayItem> targetList=displayItems.subList(gapPos, gapPos+1);
targetList.clear();
List<Status> insertedPosts=data.subList(gapPostIndex+1, gapPostIndex+1);
for(Status s:result){
if(idsBelowGap.contains(s.id))
break;
targetList.addAll(buildDisplayItems(s));
insertedPosts.add(s);
}
if(targetList.isEmpty()){
// oops. We didn't add new posts, but at least we know there are none.
adapter.notifyItemRemoved(getMainAdapterOffset()+gapPos);
}else{
adapter.notifyItemChanged(getMainAdapterOffset()+gapPos);
adapter.notifyItemRangeInserted(getMainAdapterOffset()+gapPos+1, targetList.size()-1);
}
AccountSessionManager.getInstance().getAccount(accountID).getCacheController().putHomeTimeline(insertedPosts, false);
} else {
String aboveGapID = gap.parentID;
int gapPostIndex = 0;
for (;gapPostIndex<data.size();gapPostIndex++){
if (Objects.equals(aboveGapID, data.get(gapPostIndex).id)) {
break;
}
}
// find if there's an overlap between the new data and the current data
int indexOfGapInResponse = 0;
for (;indexOfGapInResponse<result.size();indexOfGapInResponse++){
if (Objects.equals(aboveGapID, result.get(indexOfGapInResponse).id)) {
break;
}
}
// there is an overlap between new and current data
List<StatusDisplayItem> targetList=displayItems.subList(gapPos, gapPos+1);
if(indexOfGapInResponse<result.size()){
result=result.subList(indexOfGapInResponse+1,result.size());
Optional<Status> gapStatus=data.stream()
.filter(s->Objects.equals(s.id, gap.parentID))
.findFirst();
if (gapStatus.isPresent()) {
gapStatus.get().hasGapAfter=null;
AccountSessionManager.getInstance().getAccount(accountID).getCacheController().putHomeTimeline(Collections.singletonList(gapStatus.get()), false);
}
targetList.clear();
} else {
gap.loading=false;
}
List<Status> insertedPosts=data.subList(gapPostIndex+1, gapPostIndex+1);
for(Status s:result){
targetList.addAll(buildDisplayItems(s));
insertedPosts.add(s);
}
AccountSessionManager.get(accountID).filterStatuses(insertedPosts, FilterContext.HOME);
if(targetList.isEmpty()){
// oops. We didn't add new posts, but at least we know there are none.
adapter.notifyItemRemoved(getMainAdapterOffset()+gapPos);
}else{
adapter.notifyItemChanged(getMainAdapterOffset()+gapPos);
adapter.notifyItemRangeInserted(getMainAdapterOffset()+gapPos+1, targetList.size()-1);
}
list.scrollToPosition(getMainAdapterOffset()+gapPos+targetList.size());
AccountSessionManager.getInstance().getAccount(accountID).getCacheController().putHomeTimeline(insertedPosts, false);
}
}
}
@@ -445,17 +320,9 @@ public class HomeTimelineFragment extends StatusListFragment implements ToolbarD
adapter.notifyItemChanged(gapPos);
}
}
});
}
})
.exec(accountID);
private void loadAdditionalPosts(String maxID, String minID, int limit, String sinceID, Callback<List<Status>> callback){
MastodonAPIRequest<List<Status>> req=switch(listMode){
case FOLLOWING -> new GetHomeTimeline(maxID, minID, limit, sinceID);
case LOCAL -> new GetPublicTimeline(true, false, maxID, minID, limit, sinceID);
case LIST -> new GetListTimeline(currentList.id, maxID, minID, limit, sinceID);
};
currentRequest=req;
req.setCallback(callback).exec(accountID);
}
@Override
@@ -465,205 +332,22 @@ public class HomeTimelineFragment extends StatusListFragment implements ToolbarD
currentRequest=null;
dataLoading=false;
}
if(parent!=null) parent.hideNewPostsButton();
super.onRefresh();
}
private void updateToolbarLogo(){
listsDropdown=new LinearLayout(getActivity());
listsDropdown.setOnClickListener(this::onListsDropdownClick);
listsDropdown.setBackgroundResource(R.drawable.bg_button_m3_text);
listsDropdown.setAccessibilityDelegate(new View.AccessibilityDelegate(){
@Override
public void onInitializeAccessibilityNodeInfo(@NonNull View host, @NonNull AccessibilityNodeInfo info){
super.onInitializeAccessibilityNodeInfo(host, info);
info.setClassName("android.widget.Spinner");
}
});
listsDropdownArrow=new FixedAspectRatioImageView(getActivity());
listsDropdownArrow.setUseHeight(true);
listsDropdownArrow.setImageResource(R.drawable.ic_arrow_drop_down_24px);
listsDropdownArrow.setScaleType(ImageView.ScaleType.CENTER);
listsDropdownArrow.setImportantForAccessibility(View.IMPORTANT_FOR_ACCESSIBILITY_NO);
listsDropdown.addView(listsDropdownArrow, new LinearLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.MATCH_PARENT));
listsDropdownText=new TextView(getActivity());
listsDropdownText.setTextAppearance(R.style.action_bar_title);
listsDropdownText.setSingleLine();
listsDropdownText.setEllipsize(TextUtils.TruncateAt.END);
listsDropdownText.setGravity(Gravity.START | Gravity.CENTER_VERTICAL);
listsDropdownText.setPaddingRelative(V.dp(4), 0, V.dp(16), 0);
listsDropdownText.setText(getCurrentListTitle());
listsDropdownArrow.setImageTintList(listsDropdownText.getTextColors());
listsDropdown.setBackgroundTintList(listsDropdownText.getTextColors());
listsDropdown.addView(listsDropdownText, new LinearLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.MATCH_PARENT));
FrameLayout logoWrap=new FrameLayout(getActivity());
FrameLayout.LayoutParams ddlp=new FrameLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.MATCH_PARENT, Gravity.START);
ddlp.topMargin=ddlp.bottomMargin=V.dp(8);
logoWrap.addView(listsDropdown, ddlp);
Toolbar toolbar=getToolbar();
toolbar.addView(logoWrap, new Toolbar.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT));
toolbar.setContentInsetsRelative(V.dp(16), 0);
}
private void showNewPostsButton(){
if(newPostsBtnShown)
return;
newPostsBtnShown=true;
if(currentNewPostsAnim!=null){
currentNewPostsAnim.cancel();
}
newPostsBtnWrap.setVisibility(View.VISIBLE);
AnimatorSet set=new AnimatorSet();
set.playTogether(
ObjectAnimator.ofFloat(newPostsBtnWrap, View.ALPHA, 1f),
ObjectAnimator.ofFloat(newPostsBtnWrap, View.SCALE_X, 1f),
ObjectAnimator.ofFloat(newPostsBtnWrap, View.SCALE_Y, 1f),
ObjectAnimator.ofFloat(newPostsBtnWrap, View.TRANSLATION_Y, 0f)
);
set.setDuration(getResources().getInteger(R.integer.m3_sys_motion_duration_medium3));
set.setInterpolator(AnimationUtils.loadInterpolator(getActivity(), R.interpolator.m3_sys_motion_easing_standard_decelerate));
set.addListener(new AnimatorListenerAdapter(){
@Override
public void onAnimationEnd(Animator animation){
currentNewPostsAnim=null;
}
});
currentNewPostsAnim=set;
set.start();
}
private void hideNewPostsButton(){
if(!newPostsBtnShown)
return;
newPostsBtnShown=false;
if(currentNewPostsAnim!=null){
currentNewPostsAnim.cancel();
}
AnimatorSet set=new AnimatorSet();
set.playTogether(
ObjectAnimator.ofFloat(newPostsBtnWrap, View.ALPHA, 0f),
ObjectAnimator.ofFloat(newPostsBtnWrap, View.SCALE_X, .9f),
ObjectAnimator.ofFloat(newPostsBtnWrap, View.SCALE_Y, .9f),
ObjectAnimator.ofFloat(newPostsBtnWrap, View.TRANSLATION_Y, V.dp(-56))
);
set.setDuration(getResources().getInteger(R.integer.m3_sys_motion_duration_medium3));
set.setInterpolator(AnimationUtils.loadInterpolator(getActivity(), R.interpolator.m3_sys_motion_easing_standard_accelerate));
set.addListener(new AnimatorListenerAdapter(){
@Override
public void onAnimationEnd(Animator animation){
newPostsBtnWrap.setVisibility(View.GONE);
currentNewPostsAnim=null;
}
});
currentNewPostsAnim=set;
set.start();
}
private void onNewPostsBtnClick(View v){
if(newPostsBtnShown){
hideNewPostsButton();
scrollToTop();
}
}
private void onListsDropdownClick(View v){
listsDropdownArrow.animate().rotation(-180f).setDuration(150).setInterpolator(CubicBezierInterpolator.DEFAULT).start();
dropdownController.show(dropdownMainMenuController);
AccountSessionManager.get(accountID).getCacheController().reloadLists(new Callback<>(){
@Override
public void onSuccess(java.util.List<FollowList> result){
lists=result;
}
@Override
public void onError(ErrorResponse error){}
});
}
@Override
public void onDestroyView(){
super.onDestroyView();
if(GithubSelfUpdater.needSelfUpdating()){
E.unregister(this);
}
}
private void updateUpdateState(GithubSelfUpdater.UpdateState state){
if(state!=GithubSelfUpdater.UpdateState.NO_UPDATE && state!=GithubSelfUpdater.UpdateState.CHECKING)
getToolbar().getMenu().findItem(R.id.settings).setIcon(R.drawable.ic_settings_updateready_24px);
}
@Subscribe
public void onSelfUpdateStateChanged(SelfUpdateStateChangedEvent ev){
updateUpdateState(ev.state);
}
@Override
protected boolean shouldRemoveAccountPostsWhenUnfollowing(){
return true;
}
@Override
public Toolbar getToolbar(){
return super.getToolbar();
protected FilterContext getFilterContext() {
return FilterContext.HOME;
}
@Override
public void onDropdownWillDismiss(){
listsDropdownArrow.animate().rotation(0f).setDuration(150).setInterpolator(CubicBezierInterpolator.DEFAULT).start();
}
@Override
public void onDropdownDismissed(){
}
@Override
public void reload(){
if(currentRequest!=null){
currentRequest.cancel();
currentRequest=null;
}
refreshing=true;
showProgress();
loadData();
listsDropdownText.setText(getCurrentListTitle());
invalidateOptionsMenu();
}
@Override
protected RecyclerView.Adapter getAdapter(){
mergeAdapter=new MergeRecyclerAdapter();
mergeAdapter.addAdapter(super.getAdapter());
return mergeAdapter;
}
@Override
protected void onDataLoaded(List<Status> d, boolean more){
if(refreshing){
if(listMode==ListMode.LOCAL){
localTimelineBannerHelper.maybeAddBanner(list, mergeAdapter);
localTimelineBannerHelper.onBannerBecameVisible();
}else{
localTimelineBannerHelper.removeBanner(mergeAdapter);
}
}
super.onDataLoaded(d, more);
}
private String getCurrentListTitle(){
return switch(listMode){
case FOLLOWING -> getString(R.string.timeline_following);
case LOCAL -> getString(R.string.local_timeline);
case LIST -> currentList.title;
};
}
private enum ListMode{
FOLLOWING,
LOCAL,
LIST
public Uri getWebUri(Uri.Builder base) {
return base.path("/").build();
}
}

View File

@@ -0,0 +1,13 @@
package org.joinmastodon.android.fragments;
import androidx.annotation.Nullable;
import androidx.recyclerview.widget.RecyclerView;
public interface IsOnTop {
boolean isOnTop();
default boolean isRecyclerViewOnTop(@Nullable RecyclerView list) {
if (list == null) return true;
return !list.canScrollVertically(-1);
}
}

Some files were not shown because too many files have changed in this diff Show More