diff --git a/mastodon/build.gradle b/mastodon/build.gradle index 1a5712d91..c8c9e6910 100644 --- a/mastodon/build.gradle +++ b/mastodon/build.gradle @@ -20,7 +20,7 @@ android { versionName "1.3.0+fork.100.moshinda" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" resourceConfigurations += ['ar-rSA', 'ar-rDZ', 'be-rBY', 'bn-rBD', 'bs-rBA', 'ca-rES', 'cs-rCZ', 'da-rDK', 'de-rDE', 'el-rGR', 'es-rES', 'eu-rES', 'fa-rIR', 'fi-rFI', 'fil-rPH', 'fr-rFR', 'ga-rIE', 'gd-rGB', 'gl-rES', 'hi-rIN', 'hr-rHR', 'hu-rHU', 'hy-rAM', 'ig-rNG', 'in-rID', 'is-rIS', 'it-rIT', 'iw-rIL', 'ja-rJP', 'kab', 'ko-rKR', 'my-rMM', 'nl-rNL', 'no-rNO', 'oc-rFR', 'pl-rPL', 'pt-rBR', 'pt-rPT', 'ro-rRO', 'ru-rRU', 'si-rLK', 'sl-rSI', 'sv-rSE', 'th-rTH', 'tr-rTR', 'uk-rUA', 'ur-rIN', 'vi-rVN', 'zh-rCN', 'zh-rTW'] - } + } signingConfigs { nightly{ @@ -70,7 +70,7 @@ android { versionNameSuffix '-nightly+@' + properties.getProperty('CURRENT_DATE') } applicationIdSuffix '.nightly' - + signingConfig signingConfigs.nightly manifestPlaceholders = [oAuthScheme:"moshidon-android-nightly-auth"] } @@ -114,7 +114,7 @@ dependencies { implementation 'me.grishka.litex:dynamicanimation:1.1.0-alpha03' implementation 'me.grishka.litex:viewpager:1.0.0' implementation 'me.grishka.litex:viewpager2:1.0.0' - implementation 'me.grishka.appkit:appkit:1.2.7' + implementation 'me.grishka.appkit:appkit:1.2.8' implementation 'com.google.code.gson:gson:2.9.0' implementation 'org.jsoup:jsoup:1.14.3' implementation 'com.squareup:otto:1.3.8' diff --git a/mastodon/src/androidTest/java/org/joinmastodon/android/fragments/ThreadFragmentTest.java b/mastodon/src/androidTest/java/org/joinmastodon/android/fragments/ThreadFragmentTest.java index 524aaed83..38a2a489c 100644 --- a/mastodon/src/androidTest/java/org/joinmastodon/android/fragments/ThreadFragmentTest.java +++ b/mastodon/src/androidTest/java/org/joinmastodon/android/fragments/ThreadFragmentTest.java @@ -2,14 +2,14 @@ package org.joinmastodon.android.fragments; import static org.junit.Assert.*; -import android.util.Pair; - +import org.joinmastodon.android.events.StatusCountersUpdatedEvent; +import org.joinmastodon.android.events.StatusUpdatedEvent; import org.joinmastodon.android.model.Status; import org.joinmastodon.android.model.StatusContext; import org.junit.Test; +import java.time.Instant; import java.util.List; -import java.util.stream.Collectors; public class ThreadFragmentTest { @@ -20,10 +20,7 @@ public class ThreadFragmentTest { } private ThreadFragment.NeighborAncestryInfo fakeInfo(Status s, Status d, Status a) { - ThreadFragment.NeighborAncestryInfo info = new ThreadFragment.NeighborAncestryInfo(s); - info.descendantNeighbor = d; - info.ancestoringNeighbor = a; - return info; + return new ThreadFragment.NeighborAncestryInfo(s, d, a); } @Test @@ -55,6 +52,33 @@ public class ThreadFragmentTest { ), neighbors); } + @Test + public void maybeApplyMainStatus() { + ThreadFragment fragment = new ThreadFragment(); + fragment.contextInitiallyRendered = true; + fragment.mainStatus = Status.ofFake("123456", "original text", Instant.EPOCH); + + Status update1 = Status.ofFake("123456", "updated text", Instant.EPOCH); + update1.editedAt = Instant.ofEpochSecond(1); + fragment.updatedStatus = update1; + StatusUpdatedEvent event1 = (StatusUpdatedEvent) fragment.maybeApplyMainStatus(); + assertEquals("fired update event", update1, event1.status); + assertEquals("updated main status", update1, fragment.mainStatus); + + Status update2 = Status.ofFake("123456", "updated text", Instant.EPOCH); + update2.favouritesCount = 123; + fragment.updatedStatus = update2; + StatusCountersUpdatedEvent event2 = (StatusCountersUpdatedEvent) fragment.maybeApplyMainStatus(); + assertEquals("only fired counter update event", update2.id, event2.id); + assertEquals("updated counter is correct", 123, event2.favorites); + assertEquals("updated main status", update2, fragment.mainStatus); + + Status update3 = Status.ofFake("123456", "whatever", Instant.EPOCH); + fragment.contextInitiallyRendered = false; + fragment.updatedStatus = update3; + assertNull("no update when context hasn't been rendered", fragment.maybeApplyMainStatus()); + } + @Test public void sortStatusContext() { StatusContext context = new StatusContext(); diff --git a/mastodon/src/androidTest/java/org/joinmastodon/android/ui/utils/UiUtilsTest.java b/mastodon/src/androidTest/java/org/joinmastodon/android/ui/utils/UiUtilsTest.java new file mode 100644 index 000000000..63c3fb866 --- /dev/null +++ b/mastodon/src/androidTest/java/org/joinmastodon/android/ui/utils/UiUtilsTest.java @@ -0,0 +1,106 @@ +package org.joinmastodon.android.ui.utils; + +import static org.junit.Assert.*; + +import android.util.Pair; + +import org.joinmastodon.android.api.session.AccountSessionManager; +import org.joinmastodon.android.model.Account; +import org.joinmastodon.android.model.Instance; +import org.junit.AfterClass; +import org.junit.BeforeClass; +import org.junit.Test; + +import java.util.Optional; + +public class UiUtilsTest { + @BeforeClass + public static void createDummySession() { + Instance dummyInstance = new Instance(); + dummyInstance.uri = "test.tld"; + Account dummyAccount = new Account(); + dummyAccount.id = "123456"; + AccountSessionManager.getInstance().addAccount(dummyInstance, null, dummyAccount, null, null); + } + + @AfterClass + public static void cleanUp() { + AccountSessionManager.getInstance().removeAccount("test.tld_123456"); + } + + @Test + public void parseFediverseHandle() { + assertEquals( + Optional.of(Pair.create("megalodon", Optional.of("floss.social"))), + UiUtils.parseFediverseHandle("megalodon@floss.social") + ); + + assertEquals( + Optional.of(Pair.create("megalodon", Optional.of("floss.social"))), + UiUtils.parseFediverseHandle("@megalodon@floss.social") + ); + + assertEquals( + Optional.of(Pair.create("megalodon", Optional.empty())), + UiUtils.parseFediverseHandle("@megalodon") + ); + + assertEquals( + Optional.of(Pair.create("megalodon", Optional.of("floss.social"))), + UiUtils.parseFediverseHandle("mailto:megalodon@floss.social") + ); + + assertEquals( + Optional.empty(), + UiUtils.parseFediverseHandle("megalodon") + ); + + assertEquals( + Optional.empty(), + UiUtils.parseFediverseHandle("this is not a fedi handle") + ); + + assertEquals( + Optional.empty(), + UiUtils.parseFediverseHandle("not@a-domain") + ); + } + + @Test + public void acctMatches() { + assertTrue("local account, domain not specified", UiUtils.acctMatches( + "test.tld_123456", + "someone", + "someone", + null + )); + + assertTrue("domain not specified", UiUtils.acctMatches( + "test.tld_123456", + "someone@somewhere.social", + "someone", + null + )); + + assertTrue("local account, domain specified, different casing", UiUtils.acctMatches( + "test.tld_123456", + "SomeOne", + "someone", + "Test.TLD" + )); + + assertFalse("username doesn't match", UiUtils.acctMatches( + "test.tld_123456", + "someone-else@somewhere.social", + "someone", + "somewhere.social" + )); + + assertFalse("domain doesn't match", UiUtils.acctMatches( + "test.tld_123456", + "someone@somewhere.social", + "someone", + "somewhere.else" + )); + } +} \ No newline at end of file diff --git a/mastodon/src/main/java/org/joinmastodon/android/ExternalShareActivity.java b/mastodon/src/main/java/org/joinmastodon/android/ExternalShareActivity.java index be1657660..ce56efb54 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/ExternalShareActivity.java +++ b/mastodon/src/main/java/org/joinmastodon/android/ExternalShareActivity.java @@ -6,8 +6,10 @@ import android.content.Intent; 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; @@ -19,6 +21,7 @@ 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; @@ -31,19 +34,23 @@ public class ExternalShareActivity extends FragmentStackActivity{ if(savedInstanceState==null){ Optional text = Optional.ofNullable(getIntent().getStringExtra(Intent.EXTRA_TEXT)); - boolean isMastodonURL = text.map(UiUtils::looksLikeMastodonUrl).orElse(false); + Optional>> fediHandle = text.flatMap(UiUtils::parseFediverseHandle); + boolean isFediUrl = text.map(UiUtils::looksLikeFediverseUrl).orElse(false); + boolean isOpenable = isFediUrl || fediHandle.isPresent(); List 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 && !isMastodonURL){ - openComposeFragment(sessions.get(0).getID()); - }else{ - new AccountSwitcherSheet(this, null, true, isMastodonURL, (accountId, open) -> { + } else if (isOpenable || sessions.size() > 1) { + AccountSwitcherSheet sheet = new AccountSwitcherSheet(this, null, true, isOpenable); + sheet.setOnClick((accountId, open) -> { if (open && text.isPresent()) { - UiUtils.lookupURL(this, accountId, text.get(), false, (clazz, args) -> { + BiConsumer, 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; } @@ -52,11 +59,25 @@ public class ExternalShareActivity extends FragmentStackActivity{ intent.putExtras(args); finish(); startActivity(intent); - }); + }; + + fediHandle + .>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((ev) -> finish()); + })); } else { openComposeFragment(accountId); } - }).show(); + }); + sheet.show(); + } else if (sessions.size() == 1) { + openComposeFragment(sessions.get(0).getID()); } } } diff --git a/mastodon/src/main/java/org/joinmastodon/android/GlobalUserPreferences.java b/mastodon/src/main/java/org/joinmastodon/android/GlobalUserPreferences.java index 156f4a0a3..66501a16c 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/GlobalUserPreferences.java +++ b/mastodon/src/main/java/org/joinmastodon/android/GlobalUserPreferences.java @@ -33,6 +33,8 @@ public class GlobalUserPreferences{ public static boolean disableSwipe; public static boolean showDividers; public static boolean voteButtonForSingleChoice; + public static boolean enableDeleteNotifications; + public static boolean translateButtonOpenedOnly; public static boolean uniformNotificationIcon; public static boolean enableDeleteNotifications; public static boolean relocatePublishButton; @@ -55,6 +57,8 @@ public class GlobalUserPreferences{ public static boolean swapBookmarkWithBoostAction; public static boolean loadRemoteAccountFollowers; public static boolean mentionRebloggerAutomatically; + public static boolean allowRemoteLoading; + public static AutoRevealMode autoRevealEqualSpoilers; public static String publishButtonText; public static ThemePreference theme; public static ColorPreference color; @@ -77,8 +81,7 @@ public class GlobalUserPreferences{ */ public static String replyVisibility; - - public static SharedPreferences getPrefs(){ + private static SharedPreferences getPrefs(){ return MastodonApp.context.getSharedPreferences("global", Context.MODE_PRIVATE); } @@ -116,6 +119,8 @@ public class GlobalUserPreferences{ relocatePublishButton=prefs.getBoolean("relocatePublishButton", true); voteButtonForSingleChoice=prefs.getBoolean("voteButtonForSingleChoice", true); enableDeleteNotifications=prefs.getBoolean("enableDeleteNotifications", false); + translateButtonOpenedOnly=prefs.getBoolean("translateButtonOpenedOnly", false); + uniformNotificationIcon=prefs.getBoolean("uniformNotificationIcon", false); reduceMotion=prefs.getBoolean("reduceMotion", false); keepOnlyLatestNotification=prefs.getBoolean("keepOnlyLatestNotification", false); disableAltTextReminder=prefs.getBoolean("disableAltTextReminder", false); @@ -147,6 +152,8 @@ public class GlobalUserPreferences{ replyVisibility=prefs.getString("replyVisibility", null); accountsWithContentTypesEnabled=prefs.getStringSet("accountsWithContentTypesEnabled", new HashSet<>()); accountsDefaultContentTypes=fromJson(prefs.getString("accountsDefaultContentTypes", null), accountsDefaultContentTypesType, new HashMap<>()); + allowRemoteLoading=prefs.getBoolean("allowRemoteLoading", true); + autoRevealEqualSpoilers=AutoRevealMode.valueOf(prefs.getString("autoRevealEqualSpoilers", AutoRevealMode.THREADS.name())); try { if(android.os.Build.VERSION.SDK_INT >= Build.VERSION_CODES.S){ @@ -187,6 +194,7 @@ public class GlobalUserPreferences{ .putBoolean("collapseLongPosts", collapseLongPosts) .putBoolean("spectatorMode", spectatorMode) .putBoolean("autoHideFab", autoHideFab) + .putBoolean("compactReblogReplyLine", compactReblogReplyLine) .putString("publishButtonText", publishButtonText) .putBoolean("bottomEncoding", bottomEncoding) .putBoolean("defaultToUnlistedReplies", defaultToUnlistedReplies) @@ -207,6 +215,8 @@ public class GlobalUserPreferences{ .putString("replyVisibility", replyVisibility) .putStringSet("accountsWithContentTypesEnabled", accountsWithContentTypesEnabled) .putString("accountsDefaultContentTypes", gson.toJson(accountsDefaultContentTypes)) + .putBoolean("allowRemoteLoading", allowRemoteLoading) + .putString("autoRevealEqualSpoilers", autoRevealEqualSpoilers.name()) .apply(); } @@ -227,4 +237,10 @@ public class GlobalUserPreferences{ LIGHT, DARK } + + public enum AutoRevealMode { + NEVER, + THREADS, + DISCUSSIONS + } } diff --git a/mastodon/src/main/java/org/joinmastodon/android/MainActivity.java b/mastodon/src/main/java/org/joinmastodon/android/MainActivity.java index f2aed9cdf..7aea868fd 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/MainActivity.java +++ b/mastodon/src/main/java/org/joinmastodon/android/MainActivity.java @@ -118,30 +118,18 @@ public class MainActivity extends FragmentStackActivity implements ProvidesAssis } 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)); - } - fragment.setArguments(args); - showFragment(fragment); + UiUtils.showFragmentForNotification(this, notification, accountID, null); } private void showFragmentForExternalShare(Bundle args) { - String clazz = args.getString("fromExternalShare"); - Fragment fragment = switch (clazz) { + String className = args.getString("fromExternalShare"); + Fragment fragment = switch (className) { case "ThreadFragment" -> new ThreadFragment(); case "ProfileFragment" -> new ProfileFragment(); default -> null; diff --git a/mastodon/src/main/java/org/joinmastodon/android/OAuthActivity.java b/mastodon/src/main/java/org/joinmastodon/android/OAuthActivity.java index 553074599..f239bf3f0 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/OAuthActivity.java +++ b/mastodon/src/main/java/org/joinmastodon/android/OAuthActivity.java @@ -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){ diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/CacheController.java b/mastodon/src/main/java/org/joinmastodon/android/api/CacheController.java index 335d00914..3cab9c71f 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/api/CacheController.java +++ b/mastodon/src/main/java/org/joinmastodon/android/api/CacheController.java @@ -36,7 +36,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()); @@ -61,7 +61,7 @@ public class CacheController{ List filters=AccountSessionManager.getInstance().getAccount(accountID).wordFilters.stream().filter(f->f.context.contains(Filter.FilterContext.HOME)).collect(Collectors.toList()); if(!forceReload){ SQLiteDatabase db=getOrOpenDatabase(); - try(Cursor cursor=db.query("home_timeline", new String[]{"json", "flags"}, maxID==null ? null : "`id` result=new ArrayList<>(); cursor.moveToFirst(); @@ -112,7 +112,7 @@ public class CacheController{ runOnDbThread((db)->{ if(clear) db.delete("home_timeline", null, null); - ContentValues values=new ContentValues(3); + ContentValues values=new ContentValues(4); for(Status s:posts){ values.put("id", s.id); values.put("json", MastodonAPIController.gson.toJson(s)); @@ -120,6 +120,7 @@ public class CacheController{ if(s.hasGapAfter) flags|=POST_FLAG_GAP_AFTER; values.put("flags", flags); + values.put("time", s.createdAt.getEpochSecond()); db.insertWithOnConflict("home_timeline", null, values, SQLiteDatabase.CONFLICT_REPLACE); } }); @@ -134,7 +135,7 @@ public class CacheController{ if(!forceReload){ SQLiteDatabase db=getOrOpenDatabase(); String table=onlyPosts ? "notifications_posts" : onlyMentions ? "notifications_mentions" : "notifications_all"; - try(Cursor cursor=db.query(table, new String[]{"json"}, maxID==null ? null : "`id` result=new ArrayList<>(); cursor.moveToFirst(); @@ -192,7 +193,7 @@ public class CacheController{ String table=onlyPosts ? "notifications_posts" : onlyMentions ? "notifications_mentions" : "notifications_all"; if(clear) db.delete(table, null, null); - ContentValues values=new ContentValues(3); + ContentValues values=new ContentValues(4); for(Notification n:notifications){ if(n.type==null){ continue; @@ -200,6 +201,7 @@ public class CacheController{ values.put("id", n.id); values.put("json", MastodonAPIController.gson.toJson(n)); values.put("type", n.type.ordinal()); + values.put("time", n.createdAt.getEpochSecond()); db.insertWithOnConflict(table, null, values, SQLiteDatabase.CONFLICT_REPLACE); } }); @@ -296,21 +298,24 @@ public class CacheController{ CREATE TABLE `home_timeline` ( `id` VARCHAR(25) NOT NULL PRIMARY KEY, `json` TEXT NOT NULL, - `flags` INTEGER NOT NULL DEFAULT 0 + `flags` INTEGER NOT NULL DEFAULT 0, + `time` INTEGER NOT NULL )"""); db.execSQL(""" CREATE TABLE `notifications_all` ( `id` VARCHAR(25) NOT NULL PRIMARY KEY, `json` TEXT NOT NULL, `flags` INTEGER NOT NULL DEFAULT 0, - `type` INTEGER NOT NULL + `type` INTEGER NOT NULL, + `time` INTEGER NOT NULL )"""); db.execSQL(""" CREATE TABLE `notifications_mentions` ( `id` VARCHAR(25) NOT NULL PRIMARY KEY, `json` TEXT NOT NULL, `flags` INTEGER NOT NULL DEFAULT 0, - `type` INTEGER NOT NULL + `type` INTEGER NOT NULL, + `time` INTEGER NOT NULL )"""); createRecentSearchesTable(db); createPostsNotificationsTable(db); @@ -318,12 +323,16 @@ public class CacheController{ @Override public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion){ - if(oldVersion==1){ + if(oldVersion<2){ createRecentSearchesTable(db); } - if(oldVersion==2){ + if(oldVersion<3){ + // MEGALODON-SPECIFIC createPostsNotificationsTable(db); } + if(oldVersion<4){ + addTimeColumns(db); + } } private void createRecentSearchesTable(SQLiteDatabase db){ @@ -341,9 +350,21 @@ public class CacheController{ `id` VARCHAR(25) NOT NULL PRIMARY KEY, `json` TEXT NOT NULL, `flags` INTEGER NOT NULL DEFAULT 0, - `type` INTEGER NOT NULL + `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"); + } } @FunctionalInterface diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/MastodonAPIController.java b/mastodon/src/main/java/org/joinmastodon/android/api/MastodonAPIController.java index 6cefc76e6..7bd1df547 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/api/MastodonAPIController.java +++ b/mastodon/src/main/java/org/joinmastodon/android/api/MastodonAPIController.java @@ -17,6 +17,7 @@ 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; @@ -161,6 +162,11 @@ public class MastodonAPIController{ respObj=gson.fromJson(reader, req.respClass); } }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, "["+(session==null ? "no-auth" : session.getID())+"] "+response+" error parsing or reading body", x); req.onError(x.getLocalizedMessage(), response.code(), x); diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/MastodonAPIRequest.java b/mastodon/src/main/java/org/joinmastodon/android/api/MastodonAPIRequest.java index 44a740401..b6d624588 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/api/MastodonAPIRequest.java +++ b/mastodon/src/main/java/org/joinmastodon/android/api/MastodonAPIRequest.java @@ -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,9 +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; @@ -44,10 +47,11 @@ public abstract class MastodonAPIRequest extends APIRequest{ TypeToken respTypeToken; Call okhttpCall; Token token; - boolean canceled; + boolean canceled, isRemote; Map headers; private ProgressDialog progressDialog; protected boolean removeUnsupportedItems; + @Nullable Context context; public MastodonAPIRequest(HttpMethod method, String path, Class respClass){ this.path=path; @@ -101,6 +105,21 @@ public abstract class MastodonAPIRequest extends APIRequest{ return this; } + public MastodonAPIRequest execRemote(String domain) { + return execRemote(domain, null); + } + + public MastodonAPIRequest 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) + .orElse(this.execNoAuth(domain)); + } + public MastodonAPIRequest wrapProgress(Activity activity, @StringRes int message, boolean cancelable){ return wrapProgress(activity, message, cancelable, null); } @@ -164,9 +183,20 @@ public abstract class MastodonAPIRequest extends APIRequest{ return this; } + public MastodonAPIRequest 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){ @@ -175,6 +205,7 @@ public abstract class MastodonAPIRequest extends APIRequest{ 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); @@ -182,15 +213,20 @@ public abstract class MastodonAPIRequest extends APIRequest{ } } } + // 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(); + } } } } diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/StatusInteractionController.java b/mastodon/src/main/java/org/joinmastodon/android/api/StatusInteractionController.java index ec45b6d90..8baabf20a 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/api/StatusInteractionController.java +++ b/mastodon/src/main/java/org/joinmastodon/android/api/StatusInteractionController.java @@ -46,7 +46,7 @@ public class StatusInteractionController{ @Override public void onSuccess(Status result){ runningFavoriteRequests.remove(status.id); - result.favouritesCount = Math.max(0, status.favouritesCount) + (favorited ? 1 : -1); + result.favouritesCount = Math.max(0, status.favouritesCount + (favorited ? 1 : -1)); cb.accept(result); if (updateCounters) E.post(new StatusCountersUpdatedEvent(result)); } @@ -80,7 +80,7 @@ public class StatusInteractionController{ public void onSuccess(Status reblog){ Status result = reblog.getContentStatus(); runningReblogRequests.remove(status.id); - result.reblogsCount = Math.max(0, status.reblogsCount) + (reblogged ? 1 : -1); + result.reblogsCount = Math.max(0, status.reblogsCount + (reblogged ? 1 : -1)); cb.accept(result); if (updateCounters) E.post(new StatusCountersUpdatedEvent(result)); } diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/requests/accounts/GetAccountByHandle.java b/mastodon/src/main/java/org/joinmastodon/android/api/requests/accounts/GetAccountByHandle.java index 584f7819d..011a8bf91 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/api/requests/accounts/GetAccountByHandle.java +++ b/mastodon/src/main/java/org/joinmastodon/android/api/requests/accounts/GetAccountByHandle.java @@ -4,6 +4,10 @@ import org.joinmastodon.android.api.MastodonAPIRequest; import org.joinmastodon.android.model.Account; public class GetAccountByHandle extends MastodonAPIRequest{ + /** + * 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); diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/session/AccountSessionManager.java b/mastodon/src/main/java/org/joinmastodon/android/api/session/AccountSessionManager.java index 6f90a5c73..b424925bc 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/api/session/AccountSessionManager.java +++ b/mastodon/src/main/java/org/joinmastodon/android/api/session/AccountSessionManager.java @@ -171,6 +171,11 @@ public class AccountSessionManager{ return sessions.get(id); } + @Nullable + public AccountSession tryGetAccount(Account account) { + return sessions.get(account.getDomainFromURL() + "_" + account.id); + } + @Nullable public AccountSession getLastActiveAccount(){ if(sessions.isEmpty() || lastActiveAccountID==null) diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/AccountTimelineFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/AccountTimelineFragment.java index 9ddf968b6..16e6d4ac8 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/AccountTimelineFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/AccountTimelineFragment.java @@ -20,6 +20,7 @@ import org.parceler.Parcels; import java.util.Collections; import java.util.List; +import java.util.Optional; import java.util.stream.Collectors; import me.grishka.appkit.api.SimpleCallback; @@ -96,10 +97,11 @@ public class AccountTimelineFragment extends StatusListFragment{ if(ev.status.inReplyToAccountId!=null && !ev.status.inReplyToAccountId.equals(AccountSessionManager.getInstance().getAccount(accountID).self.id)) return; }else if(filter==GetAccountStatuses.Filter.MEDIA){ - if(ev.status.mediaAttachments.isEmpty()) + if(Optional.ofNullable(ev.status.mediaAttachments).map(List::isEmpty).orElse(true)) return; } prependItems(Collections.singletonList(ev.status), true); + if (isOnTop()) scrollToTop(); } protected void onStatusUnpinned(StatusUnpinnedEvent ev){ diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/BaseStatusListFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/BaseStatusListFragment.java index 3c6288693..4a787865a 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/BaseStatusListFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/BaseStatusListFragment.java @@ -16,7 +16,6 @@ import android.text.TextPaint; import android.text.TextUtils; import android.view.View; import android.view.ViewGroup; -import android.view.ViewTreeObserver; import android.view.WindowInsets; import android.view.animation.TranslateAnimation; import android.widget.ImageButton; @@ -39,6 +38,7 @@ import org.joinmastodon.android.model.Relationship; import org.joinmastodon.android.model.Status; import org.joinmastodon.android.ui.BetterItemAnimator; 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.HeaderStatusDisplayItem; import org.joinmastodon.android.ui.displayitems.MediaGridStatusDisplayItem; @@ -61,6 +61,10 @@ import java.util.List; import java.util.Set; 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; @@ -72,7 +76,7 @@ import me.grishka.appkit.utils.BindableViewHolder; import me.grishka.appkit.utils.V; import me.grishka.appkit.views.UsableRecyclerView; -public abstract class BaseStatusListFragment extends RecyclerFragment implements PhotoViewerHost, ScrollableToTop, HasFab, ProvidesAssistContent.ProvidesWebUri, DomainDisplay { +public abstract class BaseStatusListFragment extends RecyclerFragment implements PhotoViewerHost, ScrollableToTop, IsOnTop, HasFab, ProvidesAssistContent.ProvidesWebUri { protected ArrayList displayItems=new ArrayList<>(); protected DisplayItemsAdapter adapter; protected String accountID; @@ -83,8 +87,7 @@ public abstract class BaseStatusListFragment exten protected HashMap relationships=new HashMap<>(); protected Rect tmpRect=new Rect(); protected TypedObjectPool attachmentViewsPool=new TypedObjectPool<>(this::makeNewMediaAttachmentView); - - private final int THRESHOLD = 800; + protected boolean currentlyScrolling; public BaseStatusListFragment(){ super(20); @@ -98,7 +101,6 @@ public abstract class BaseStatusListFragment exten @Override public void onCreate(Bundle savedInstanceState){ super.onCreate(savedInstanceState); - UiUtils.loadMaxWidth(getContext()); if(GlobalUserPreferences.disableMarquee){ setTitleMarqueeEnabled(false); setSubtitleMarqueeEnabled(false); @@ -295,6 +297,10 @@ public abstract class BaseStatusListFragment exten fab.startAnimation(animate); } + public boolean isScrolling() { + return currentlyScrolling; + } + @Override public void hideFab() { View fab = getFab(); @@ -322,7 +328,7 @@ public abstract class BaseStatusListFragment exten currentPhotoViewer.offsetView(-dx, -dy); View fab = getFab(); - if (fab!=null && GlobalUserPreferences.autoHideFab) { + 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) { @@ -335,6 +341,12 @@ public abstract class BaseStatusListFragment exten } } } + + @Override + public void onScrollStateChanged(@NonNull RecyclerView recyclerView, int newState) { + super.onScrollStateChanged(recyclerView, newState); + currentlyScrolling = newState != RecyclerView.SCROLL_STATE_IDLE; + } }); list.addItemDecoration(new StatusListItemDecoration()); ((UsableRecyclerView)list).setSelectorBoundsProvider(new UsableRecyclerView.SelectorBoundsProvider(){ @@ -378,7 +390,9 @@ public abstract class BaseStatusListFragment exten } // shifting the selection box down // see also: FooterStatusDisplayItem#onBind (setMargins) - if (isWarning || firstIndex < 0 || lastIndex < 0) return; + 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)) @@ -488,26 +502,12 @@ public abstract class BaseStatusListFragment exten } } }else{ - if(holder.getItem().status.reloadWhenClicked){ - Status queryStatus = holder.getItem().status; - UiUtils.lookupStatus(getContext(), queryStatus, accountID, null, status -> { - submitPollVote(holder.getItemID(), status.poll.id, poll.selectedOptions.stream().map(opt->poll.options.indexOf(opt)).collect(Collectors.toList())); - }); - return; - } submitPollVote(holder.getItemID(), poll.id, Collections.singletonList(poll.options.indexOf(option))); } } public void onPollVoteButtonClick(PollFooterStatusDisplayItem.Holder holder){ Poll poll=holder.getItem().poll; - if(holder.getItem().status.reloadWhenClicked){ - Status queryStatus = holder.getItem().status; - UiUtils.lookupStatus(getContext(), queryStatus, accountID, null, status -> { - submitPollVote(holder.getItemID(), status.poll.id, poll.selectedOptions.stream().map(opt->poll.options.indexOf(opt)).collect(Collectors.toList())); - }); - return; - } submitPollVote(holder.getItemID(), poll.id, poll.selectedOptions.stream().map(opt->poll.options.indexOf(opt)).collect(Collectors.toList())); } @@ -686,8 +686,8 @@ public abstract class BaseStatusListFragment exten } @Override - public boolean isScrolledToTop() { - return list.getChildAt(0) == null || list.getChildAt(0).getTop() == 0; + public boolean isOnTop() { + return isRecyclerViewOnTop(list); } protected int getListWidthForMediaLayout(){ @@ -759,6 +759,13 @@ public abstract class BaseStatusListFragment exten assistContent.setWebUri(getWebUri(getSession().getInstanceUri().buildUpon())); } + @Override + protected void onDataLoaded(List d, boolean more) { + super.onDataLoaded(d, more); + // more available, but the page isn't even full yet? seems wrong, let's load some more + if (more && d.size() < itemsPerPage) preloader.onScrolledToLastItem(); + } + protected class DisplayItemsAdapter extends UsableRecyclerView.Adapter> implements ImageLoaderRecyclerAdapter{ public DisplayItemsAdapter(){ diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/ComposeFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/ComposeFragment.java index db75e9835..fd9afde61 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/ComposeFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/ComposeFragment.java @@ -152,7 +152,7 @@ import me.grishka.appkit.imageloader.ViewImageLoader; import me.grishka.appkit.imageloader.requests.UrlImageLoaderRequest; import me.grishka.appkit.utils.V; -public class ComposeFragment extends MastodonToolbarFragment implements OnBackPressedListener, ComposeEditText.SelectionListener{ +public class ComposeFragment extends MastodonToolbarFragment implements OnBackPressedListener, ComposeEditText.SelectionListener, HasAccountID { private static final int MEDIA_RESULT=717; private static final int IMAGE_DESCRIPTION_RESULT=363; @@ -378,6 +378,7 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr } else { mediaBtn.setOnClickListener(v -> openFilePicker(false)); } + if (isInstancePixelfed()) pollBtn.setVisibility(View.GONE); pollBtn.setOnClickListener(v->togglePoll()); emojiBtn.setOnClickListener(v->emojiKeyboard.toggleKeyboardPopup(mainEditText)); spoilerBtn.setOnClickListener(v->toggleSpoiler()); @@ -602,8 +603,10 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr @Override public void afterTextChanged(Editable s){ - if(s.length()==0) + if(s.length()==0){ + updateCharCounter(); return; + } int start=lastChangeStart; int count=lastChangeCount; // offset one char back to catch an already typed '@' or '#' or ':' @@ -886,12 +889,18 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr updateScheduledAt(scheduledAt != null ? scheduledAt : scheduledStatus != null ? scheduledStatus.scheduledAt : null); buildLanguageSelector(languageButton); - if (editingStatus != null && scheduledStatus == null) { + if (isInstancePixelfed()) spoilerBtn.setVisibility(View.GONE); + if (isInstancePixelfed() || (editingStatus != null && scheduledStatus == null)) { // editing an already published post draftsBtn.setVisibility(View.GONE); } } + @Override + public String getAccountID() { + return accountID; + } + private void navigateToUnsentPosts() { Bundle args=new Bundle(); args.putString("account", accountID); @@ -1051,8 +1060,8 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr if(att.state!=AttachmentUploadState.DONE) nonDoneAttachmentCount++; } - publishButton.setEnabled((trimmedCharCount>0 || !attachments.isEmpty()) && charCount<=charLimit && nonDoneAttachmentCount==0 && (pollOptions.isEmpty() || nonEmptyPollOptionsCount>1)); -// sendError.setVisibility(View.GONE); + publishButton.setEnabled((!isInstancePixelfed() || attachments.size() > 0) && (trimmedCharCount>0 || !attachments.isEmpty()) && charCount<=charLimit && nonDoneAttachmentCount==0 && (pollOptions.isEmpty() || nonEmptyPollOptionsCount>1)); + sendError.setVisibility(View.GONE); } private void onCustomEmojiClick(Emoji emoji){ @@ -1070,18 +1079,7 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr } private void onPublishClick(View v){ - if (!attachments.isEmpty() - && statusVisibility != StatusPrivacy.DIRECT - && !attachments.stream().allMatch(attachment -> attachment.description != null && !attachment.description.isBlank())) { - new M3AlertDialogBuilder(getActivity()) - .setTitle(R.string.mo_no_image_desc_title) - .setMessage(R.string.mo_no_image_desc) - .setNegativeButton(R.string.cancel, null) - .setPositiveButton(R.string.publish, (dialog, i)-> publish()) - .show(); - } else { - publish(); - } + publish(); } private void publishErrorCallback(ErrorResponse error) { @@ -1204,7 +1202,7 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr sendProgress.setVisibility(View.VISIBLE); sendError.setVisibility(View.GONE); - Callback resCallback=new Callback<>(){ + Callback resCallback = new Callback<>(){ @Override public void onSuccess(Status result){ maybeDeleteScheduledPost(() -> { @@ -1217,7 +1215,17 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr E.post(new StatusCountersUpdatedEvent(replyTo)); } }else{ - E.post(new StatusUpdatedEvent(result)); + // pixelfed doesn't return the edited status :/ + Status editedStatus = result == null ? editingStatus : result; + if (result == null) { + editedStatus.text = req.status; + editedStatus.spoilerText = req.spoilerText; + editedStatus.sensitive = req.sensitive; + editedStatus.language = req.language; + // user will have to reload to see html + editedStatus.content = req.status; + } + E.post(new StatusUpdatedEvent(editedStatus)); } if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O || !isStateSaved()) { Nav.finish(ComposeFragment.this); @@ -1238,7 +1246,6 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr } }; - if(editingStatus!=null && !redraftStatus){ new EditStatus(req, editingStatus.id) .setCallback(resCallback) @@ -1422,7 +1429,6 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr } } - @SuppressLint("StringFormatInvalid") private boolean addMediaAttachment(Uri uri, String description){ if(getMediaAttachmentsCount()==MAX_ATTACHMENTS){ showMediaAttachmentError(getResources().getQuantityString(R.plurals.cant_add_more_than_x_attachments, MAX_ATTACHMENTS, MAX_ATTACHMENTS)); @@ -1546,7 +1552,7 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr private void uploadMediaAttachment(DraftMediaAttachment attachment){ if(areThereAnyUploadingAttachments()){ - throw new IllegalStateException("there is already an attachment being uploaded"); + throw new IllegalStateException("there is already an attachment being uploaded"); } attachment.state=AttachmentUploadState.UPLOADING; attachment.progressBar.setVisibility(View.VISIBLE); @@ -1745,6 +1751,10 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr DraftMediaAttachment att=(DraftMediaAttachment) v.getTag(); if(att.serverAttachment==null) return; + editMediaDescription(att); + } + + private void editMediaDescription(DraftMediaAttachment att) { Bundle args=new Bundle(); args.putString("account", accountID); args.putString("attachment", att.serverAttachment.id); @@ -1965,9 +1975,12 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr visibilityPopup=new PopupMenu(getActivity(), v); visibilityPopup.inflate(R.menu.compose_visibility); Menu m=visibilityPopup.getMenu(); + if (isInstancePixelfed()) { + m.findItem(R.id.vis_private).setVisible(false); + } MenuItem localOnlyItem = visibilityPopup.getMenu().findItem(R.id.local_only); boolean prefsSaysSupported = GlobalUserPreferences.accountsWithLocalOnlySupport.contains(accountID); - if (instance.isAkkoma()) { + if (isInstanceAkkoma()) { m.findItem(R.id.vis_local).setVisible(true); } else if (localOnly || prefsSaysSupported) { localOnlyItem.setVisible(true); @@ -2191,14 +2204,6 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr } }); } - private void editMediaDescription(DraftMediaAttachment att) { - Bundle args=new Bundle(); - args.putString("account", accountID); - args.putString("attachment", att.serverAttachment.id); - args.putParcelable("uri", att.uri); - args.putString("existingDescription", att.description); - Nav.goForResult(getActivity(), ComposeImageDescriptionFragment.class, args, IMAGE_DESCRIPTION_RESULT, this); - } @Override public CharSequence getTitle(){ diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/HasAccountID.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/HasAccountID.java index 67a3bbfac..5e7f01fed 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/HasAccountID.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/HasAccountID.java @@ -17,6 +17,10 @@ public interface HasAccountID { return getInstance().map(Instance::isAkkoma).orElse(false); } + default boolean isInstancePixelfed() { + return getInstance().map(Instance::isPixelfed).orElse(false); + } + default Optional getInstance() { return getSession().getInstance(); } diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/HasFab.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/HasFab.java index 056e80044..937e64757 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/HasFab.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/HasFab.java @@ -6,4 +6,5 @@ public interface HasFab { View getFab(); void showFab(); void hideFab(); + boolean isScrolling(); } diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/HomeFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/HomeFragment.java index e241a70e3..7e6e7d7f8 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/HomeFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/HomeFragment.java @@ -252,7 +252,7 @@ public class HomeFragment extends AppKitFragment implements OnBackPressedListene } getChildFragmentManager().beginTransaction().hide(fragmentForTab(currentTab)).show(newFragment).commit(); maybeTriggerLoading(newFragment); - if (newFragment instanceof HasFab fabulous) fabulous.showFab(); + if (newFragment instanceof HasFab fabulous && !fabulous.isScrolling()) fabulous.showFab(); currentTab=tab; ((FragmentStackActivity)getActivity()).invalidateSystemBarColors(this); if (tab == R.id.tab_search && isPleroma) searchFragment.selectSearch(); diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/HomeTabFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/HomeTabFragment.java index 57df424da..59d7f3263 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/HomeTabFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/HomeTabFragment.java @@ -466,6 +466,12 @@ public class HomeTabFragment extends MastodonToolbarFragment implements Scrollab if (fragments[pager.getCurrentItem()] instanceof BaseStatusListFragment l) l.hideFab(); } + @Override + public boolean isScrolling() { + return (fragments[pager.getCurrentItem()] instanceof HasFab fabulous) + && fabulous.isScrolling(); + } + private void updateSwitcherIcon(int i) { timelineIcon.setImageResource(timelines[i].getIcon().iconRes); timelineTitle.setText(timelines[i].getTitle(getContext())); diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/MastodonToolbarFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/MastodonToolbarFragment.java index 93cb88fdf..f690f1a39 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/MastodonToolbarFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/MastodonToolbarFragment.java @@ -30,4 +30,9 @@ public abstract class MastodonToolbarFragment extends ToolbarFragment{ toolbar.setNavigationContentDescription(R.string.back); } } + + @Override + protected boolean wantsToolbarMenuIconsTinted() { + return false; // else, badged icons don't work :( + } } diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/NotificationsListFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/NotificationsListFragment.java index 176adcfe1..c01d88f15 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/NotificationsListFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/NotificationsListFragment.java @@ -16,6 +16,7 @@ import org.joinmastodon.android.api.session.AccountSessionManager; import org.joinmastodon.android.events.AllNotificationsSeenEvent; import org.joinmastodon.android.events.PollUpdatedEvent; import org.joinmastodon.android.events.RemoveAccountPostsEvent; +import org.joinmastodon.android.events.StatusCountersUpdatedEvent; import org.joinmastodon.android.model.Account; import org.joinmastodon.android.model.CacheablePaginatedResponse; import org.joinmastodon.android.model.Emoji; @@ -25,6 +26,8 @@ import org.joinmastodon.android.model.Markers; import org.joinmastodon.android.model.Notification; import org.joinmastodon.android.model.Status; import org.joinmastodon.android.ui.displayitems.AccountCardStatusDisplayItem; +import org.joinmastodon.android.ui.displayitems.ExtendedFooterStatusDisplayItem; +import org.joinmastodon.android.ui.displayitems.FooterStatusDisplayItem; import org.joinmastodon.android.ui.displayitems.HeaderStatusDisplayItem; import org.joinmastodon.android.ui.displayitems.StatusDisplayItem; import org.joinmastodon.android.ui.displayitems.TextStatusDisplayItem; @@ -192,23 +195,10 @@ public class NotificationsListFragment extends BaseStatusListFragment() { - @Override - public void onSuccess(Relationship result) {} - - @Override - public void onError(ErrorResponse error) { - error.showToast(getActivity()); - } - }).exec(accountID); + private void onAccountLoaded(Account result) { + account=result; + isOwnProfile=AccountSessionManager.getInstance().isSelf(accountID, account); + bindHeaderView(); + dataLoaded(); + if(!tabLayoutMediator.isAttached()) + tabLayoutMediator.attach(); + if(!isOwnProfile) + loadRelationship(); + else + AccountSessionManager.getInstance().updateAccountInfo(accountID, account); + if(refreshing){ + refreshing=false; + refreshLayout.setRefreshing(false); + if(postsFragment.loaded) + postsFragment.onRefresh(); + if(postsWithRepliesFragment.loaded) + postsWithRepliesFragment.onRefresh(); + if(pinnedPostsFragment.loaded) + pinnedPostsFragment.onRefresh(); + if(mediaFragment.loaded) + mediaFragment.onRefresh(); + } + V.setVisibilityAnimated(fab, View.VISIBLE); } @Override protected void doLoadData(){ + if (remoteAccount != null) { + UiUtils.lookupAccountHandle(getContext(), accountID, remoteAccount.getFullyQualifiedName(), (c, args) -> { + if (getContext() == null) return; + if (args == null || !args.containsKey("profileAccount")) { + Toast.makeText(getContext(), getContext().getString( + R.string.sk_error_loading_profile, domain + ), Toast.LENGTH_SHORT).show(); + Nav.finish(this); + return; + } + onAccountLoaded(Parcels.unwrap(args.getParcelable("profileAccount"))); + }); + return; + } + currentRequest=new GetAccountByID(profileAccountID) .setCallback(new SimpleCallback<>(this){ @Override public void onSuccess(Account result){ if (getActivity() == null) return; - account=result; - isOwnProfile=AccountSessionManager.getInstance().isSelf(accountID, account); - bindHeaderView(); - dataLoaded(); - if(!tabLayoutMediator.isAttached()) - tabLayoutMediator.attach(); - if(!isOwnProfile) - loadRelationship(); - else - AccountSessionManager.getInstance().updateAccountInfo(accountID, account); - if(refreshing){ - refreshing=false; - refreshLayout.setRefreshing(false); - if(postsFragment.loaded) - postsFragment.onRefresh(); - if(postsWithRepliesFragment.loaded) - postsWithRepliesFragment.onRefresh(); - if(pinnedPostsFragment.loaded) - pinnedPostsFragment.onRefresh(); - if(mediaFragment.loaded) - mediaFragment.onRefresh(); - } - V.setVisibilityAnimated(fab, View.VISIBLE); + onAccountLoaded(result); } }) .exec(accountID); @@ -537,7 +541,7 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList toolbarTitleView.setTranslationY(titleTransY); toolbarSubtitleView.setTranslationY(titleTransY); } -// RecyclerFragment.setRefreshLayoutColors(refreshLayout); + RecyclerFragment.setRefreshLayoutColors(refreshLayout); } @Override @@ -606,13 +610,12 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList boolean isSelf=AccountSessionManager.getInstance().isSelf(accountID, account); + String acct = ((isSelf || account.isRemote) + ? account.getFullyQualifiedName() + : account.acct); if(account.locked){ ssb=new SpannableStringBuilder("@"); - ssb.append(account.acct); - if(isSelf){ - ssb.append('@'); - ssb.append(domain); - } + ssb.append(acct); ssb.append(" "); Drawable lock=username.getResources().getDrawable(R.drawable.ic_lock, getActivity().getTheme()).mutate(); lock.setBounds(0, 0, lock.getIntrinsicWidth(), lock.getIntrinsicHeight()); @@ -634,7 +637,7 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList username.setText(ssb); }else{ // noinspection SetTextI18n - username.setText('@'+account.acct+(isSelf ? ('@'+domain) : "")); + username.setText('@'+acct); } CharSequence parsedBio = null; if(account.note != null){ @@ -707,6 +710,11 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList return false; } + @Override + protected boolean wantsToolbarMenuIconsTinted() { + return false; + } + @Override public void onCreateOptionsMenu(Menu menu, MenuInflater inflater){ if(isOwnProfile && isInEditMode){ @@ -741,8 +749,10 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList )); } menu.findItem(R.id.share).setTitle(getString(R.string.share_user, account.getShortUsername())); - if(isOwnProfile) + if(isOwnProfile) { + if (isInstancePixelfed()) menu.findItem(R.id.scheduled).setVisible(false); return; + } MenuItem mute = menu.findItem(R.id.mute); mute.setTitle(getString(relationship.muting ? R.string.unmute_user : R.string.mute_user, account.getShortUsername())); @@ -893,6 +903,12 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList if (getFragmentForPage(pager.getCurrentItem()) instanceof HasFab fabulous) fabulous.hideFab(); } + @Override + public boolean isScrolling() { + return getFragmentForPage(pager.getCurrentItem()) instanceof HasFab fabulous + && fabulous.isScrolling(); + } + private void onScrollChanged(View v, int scrollX, int scrollY, int oldScrollX, int oldScrollY){ int topBarsH=getToolbar().getHeight()+statusBarHeight; if(scrollY>avatarBorder.getTop()-topBarsH){ @@ -1151,6 +1167,16 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList return false; } + private List createFakeAttachments(String url, Drawable drawable){ + Attachment att=new Attachment(); + att.type=Attachment.Type.IMAGE; + att.url=url; + att.meta=new Attachment.Metadata(); + att.meta.width=drawable.getIntrinsicWidth(); + att.meta.height=drawable.getIntrinsicHeight(); + return Collections.singletonList(att); + } + private void onNotifyButtonClick(View v) { UiUtils.performToggleAccountNotifications(getActivity(), account, accountID, relationship, actionButton, this::setNotifyProgressVisible, this::updateRelationship); } @@ -1163,7 +1189,7 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList if(ava==null) return; int radius=V.dp(25); - currentPhotoViewer=new PhotoViewer(getActivity(), Attachment.createFakeAttachments(account.avatar, ava), 0, + currentPhotoViewer=new PhotoViewer(getActivity(), createFakeAttachments(account.avatar, ava), 0, new SingleImagePhotoViewerListener(avatar, avatarBorder, new int[]{radius, radius, radius, radius}, this, ()->currentPhotoViewer=null, ()->ava, null, null)); } } @@ -1175,7 +1201,7 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList Drawable drawable=cover.getDrawable(); if(drawable==null || drawable instanceof ColorDrawable) return; - currentPhotoViewer=new PhotoViewer(getActivity(), Attachment.createFakeAttachments(account.header, drawable), 0, + currentPhotoViewer=new PhotoViewer(getActivity(), createFakeAttachments(account.header, drawable), 0, new SingleImagePhotoViewerListener(cover, cover, null, this, ()->currentPhotoViewer=null, ()->drawable, ()->avatarBorder.setTranslationZ(2), ()->avatarBorder.setTranslationZ(0))); } } @@ -1207,11 +1233,6 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList } } - @Override - public boolean isScrolledToTop() { - return list.getChildAt(0).getTop() == 0; - } - @Override public void scrollToTop(){ getScrollableRecyclerView().scrollToPosition(0); diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/ScrollableToTop.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/ScrollableToTop.java index 6d4598738..f23e6f481 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/ScrollableToTop.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/ScrollableToTop.java @@ -3,7 +3,8 @@ package org.joinmastodon.android.fragments; import android.view.ViewTreeObserver; import androidx.recyclerview.widget.RecyclerView; -import me.grishka.appkit.utils.V; + +import org.joinmastodon.android.ui.utils.UiUtils; public interface ScrollableToTop{ boolean isScrolledToTop(); @@ -23,7 +24,7 @@ public interface ScrollableToTop{ @Override public boolean onPreDraw(){ list.getViewTreeObserver().removeOnPreDrawListener(this); - list.scrollBy(0, V.dp(300)); + list.scrollBy(0, UiUtils.SCROLL_TO_TOP_DELTA); list.smoothScrollToPosition(0); return true; } diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/SettingsFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/SettingsFragment.java index 5ceeb5e74..29e2150e7 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/SettingsFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/SettingsFragment.java @@ -37,6 +37,7 @@ import com.squareup.otto.Subscribe; import org.joinmastodon.android.BuildConfig; import org.joinmastodon.android.E; import org.joinmastodon.android.GlobalUserPreferences; +import org.joinmastodon.android.GlobalUserPreferences.AutoRevealMode; import org.joinmastodon.android.GlobalUserPreferences.ColorPreference; import org.joinmastodon.android.MainActivity; import org.joinmastodon.android.MastodonApp; @@ -85,8 +86,8 @@ public class SettingsFragment extends MastodonToolbarFragment implements Provide private ArrayList items=new ArrayList<>(); private ThemeItem themeItem; private NotificationPolicyItem notificationPolicyItem; - private SwitchItem showNewPostsItem, glitchModeItem, compactReblogReplyLineItem; - private ButtonItem defaultContentTypeButtonItem; + private SwitchItem showNewPostsItem, glitchModeItem, compactReblogReplyLineItem, alwaysRevealSpoilersItem; + private ButtonItem defaultContentTypeButtonItem, autoRevealSpoilersItem; private String accountID; private boolean needUpdateNotificationSettings; private boolean needAppRestart; @@ -189,9 +190,18 @@ public class SettingsFragment extends MastodonToolbarFragment implements Provide GlobalUserPreferences.showInteractionCounts=i.checked; GlobalUserPreferences.save(); })); - items.add(new SwitchItem(R.string.sk_settings_always_reveal_content_warnings, R.drawable.ic_fluent_chat_warning_24_regular, GlobalUserPreferences.alwaysExpandContentWarnings, i->{ + items.add(alwaysRevealSpoilersItem = new SwitchItem(R.string.sk_settings_always_reveal_content_warnings, R.drawable.ic_fluent_chat_warning_24_regular, GlobalUserPreferences.alwaysExpandContentWarnings, i->{ GlobalUserPreferences.alwaysExpandContentWarnings=i.checked; GlobalUserPreferences.save(); + if (list.findViewHolderForAdapterPosition(items.indexOf(autoRevealSpoilersItem)) instanceof ButtonViewHolder bvh) bvh.rebind(); + })); + items.add(autoRevealSpoilersItem = new ButtonItem(R.string.sk_settings_auto_reveal_equal_spoilers, R.drawable.ic_fluent_eye_24_regular, b->{ + PopupMenu popupMenu=new PopupMenu(getActivity(), b, Gravity.CENTER_HORIZONTAL); + popupMenu.inflate(R.menu.settings_auto_reveal_spoiler); + popupMenu.setOnMenuItemClickListener(i -> onAutoRevealSpoilerClick(i, b)); + b.setOnTouchListener(popupMenu.getDragToOpenListener()); + b.setOnClickListener(v->popupMenu.show()); + onAutoRevealSpoilerChanged(b); })); items.add(new SwitchItem(R.string.sk_tabs_disable_swipe, R.drawable.ic_fluent_swipe_right_24_regular, GlobalUserPreferences.disableSwipe, i->{ GlobalUserPreferences.disableSwipe=i.checked; @@ -219,6 +229,11 @@ public class SettingsFragment extends MastodonToolbarFragment implements Provide GlobalUserPreferences.confirmBeforeReblog=i.checked; GlobalUserPreferences.save(); })); + items.add(new SwitchItem(R.string.sk_settings_allow_remote_loading, R.drawable.ic_fluent_communication_24_regular, GlobalUserPreferences.allowRemoteLoading, i->{ + GlobalUserPreferences.allowRemoteLoading=i.checked; + GlobalUserPreferences.save(); + })); + items.add(new SmallTextItem(R.string.sk_settings_allow_remote_loading_explanation)); items.add(new HeaderItem(R.string.sk_timelines)); items.add(new SwitchItem(R.string.sk_settings_show_replies, R.drawable.ic_fluent_chat_multiple_24_regular, GlobalUserPreferences.showReplies, i->{ @@ -271,7 +286,7 @@ public class SettingsFragment extends MastodonToolbarFragment implements Provide GlobalUserPreferences.collapseLongPosts=i.checked; GlobalUserPreferences.save(); })); - items.add(new SwitchItem(R.string.sk_settings_hide_interaction, R.drawable.ic_fluent_eye_24_regular, GlobalUserPreferences.spectatorMode, i->{ + items.add(new SwitchItem(R.string.sk_settings_hide_interaction, R.drawable.ic_fluent_star_off_24_regular, GlobalUserPreferences.spectatorMode, i->{ GlobalUserPreferences.spectatorMode=i.checked; GlobalUserPreferences.save(); needAppRestart=true; @@ -526,6 +541,36 @@ public class SettingsFragment extends MastodonToolbarFragment implements Provide return true; } + private boolean onAutoRevealSpoilerClick(MenuItem item, Button btn) { + int id = item.getItemId(); + + AutoRevealMode mode = AutoRevealMode.NEVER; + if (id == R.id.auto_reveal_threads) mode = AutoRevealMode.THREADS; + else if (id == R.id.auto_reveal_discussions) mode = AutoRevealMode.DISCUSSIONS; + + GlobalUserPreferences.alwaysExpandContentWarnings = false; + GlobalUserPreferences.autoRevealEqualSpoilers = mode; + GlobalUserPreferences.save(); + onAutoRevealSpoilerChanged(btn); + return true; + } + + private void onAutoRevealSpoilerChanged(Button b) { + if (GlobalUserPreferences.alwaysExpandContentWarnings) { + b.setText(R.string.sk_settings_auto_reveal_always); + } else { + b.setText(switch(GlobalUserPreferences.autoRevealEqualSpoilers){ + case THREADS -> R.string.sk_settings_auto_reveal_threads; + case DISCUSSIONS -> R.string.sk_settings_auto_reveal_discussions; + default -> R.string.sk_settings_auto_reveal_never; + }); + if (alwaysRevealSpoilersItem.checked != GlobalUserPreferences.alwaysExpandContentWarnings) { + alwaysRevealSpoilersItem.checked = GlobalUserPreferences.alwaysExpandContentWarnings; + if (list.findViewHolderForAdapterPosition(items.indexOf(alwaysRevealSpoilersItem)) instanceof SwitchViewHolder svh) svh.rebind(); + } + } + } + private void onTrueBlackThemeChanged(SwitchItem item){ GlobalUserPreferences.trueBlackTheme=item.checked; GlobalUserPreferences.save(); @@ -555,14 +600,14 @@ public class SettingsFragment extends MastodonToolbarFragment implements Provide private boolean onContentTypeChanged(MenuItem item, Button btn){ int id = item.getItemId(); - ContentType contentType = switch (id) { - case R.id.content_type_plain -> ContentType.PLAIN; - case R.id.content_type_html -> ContentType.HTML; - case R.id.content_type_markdown -> ContentType.MARKDOWN; - case R.id.content_type_bbcode -> ContentType.BBCODE; - case R.id.content_type_misskey_markdown -> ContentType.MISSKEY_MARKDOWN; - default -> null; - }; + + ContentType contentType = null; + if (id == R.id.content_type_plain) contentType = ContentType.PLAIN; + else if (id == R.id.content_type_html) contentType = ContentType.HTML; + else if (id == R.id.content_type_markdown) contentType = ContentType.MARKDOWN; + else if (id == R.id.content_type_bbcode) contentType = ContentType.BBCODE; + else if (id == R.id.content_type_misskey_markdown) contentType = ContentType.MISSKEY_MARKDOWN; + GlobalUserPreferences.accountsDefaultContentTypes.put(accountID, contentType); GlobalUserPreferences.save(); btn.setText(getContentTypeString(contentType)); @@ -835,7 +880,11 @@ public class SettingsFragment extends MastodonToolbarFragment implements Provide } private class SmallTextItem extends Item { - private String text; + private final String text; + + public SmallTextItem(@StringRes int text) { + this.text = getString(text); + } public SmallTextItem(String text) { this.text = text; @@ -1076,7 +1125,6 @@ public class SettingsFragment extends MastodonToolbarFragment implements Provide private final ImageView icon; private final TextView text; - @SuppressLint("ClickableViewAccessibility") public ButtonViewHolder(){ super(getActivity(), R.layout.item_settings_button, list); text=findViewById(R.id.text); @@ -1084,14 +1132,17 @@ public class SettingsFragment extends MastodonToolbarFragment implements Provide button=findViewById(R.id.button); } + @SuppressLint("ClickableViewAccessibility") @Override public void onBind(ButtonItem item){ text.setText(item.text); - if (item.icon == 0) { - icon.setVisibility(View.GONE); - } else { - icon.setImageResource(item.icon); - } + icon.setVisibility(item.icon == 0 ? View.GONE : View.VISIBLE); + icon.setImageResource(item.icon == 0 ? 0 : item.icon); + // reset listeners before letting the button consumer consume the button + // (and potentially set some listeners, but not others) + button.setOnTouchListener(null); + button.setOnClickListener(null); + button.setOnLongClickListener(null); item.buttonConsumer.accept(button); } } diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/StatusListFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/StatusListFragment.java index 0e400fd6e..c3ca162df 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/StatusListFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/StatusListFragment.java @@ -82,7 +82,7 @@ public abstract class StatusListFragment extends BaseStatusListFragment status.filterRevealed = true; Bundle args=new Bundle(); args.putString("account", accountID); - args.putParcelable("status", Parcels.wrap(status)); + args.putParcelable("status", Parcels.wrap(status.clone())); if(status.inReplyToAccountId!=null && knownAccounts.containsKey(status.inReplyToAccountId)) args.putParcelable("inReplyToAccount", Parcels.wrap(knownAccounts.get(status.inReplyToAccountId))); Nav.go(getActivity(), ThreadFragment.class, args); diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/ThreadFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/ThreadFragment.java index 5b8872f8f..6ff24b7b7 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/ThreadFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/ThreadFragment.java @@ -2,22 +2,25 @@ package org.joinmastodon.android.fragments; import android.net.Uri; import android.os.Bundle; -import android.util.Pair; import android.view.View; -import org.joinmastodon.android.DomainManager; import androidx.annotation.NonNull; +import androidx.recyclerview.widget.RecyclerView; +import org.joinmastodon.android.E; +import org.joinmastodon.android.GlobalUserPreferences; +import org.joinmastodon.android.GlobalUserPreferences.AutoRevealMode; import org.joinmastodon.android.R; +import org.joinmastodon.android.api.requests.statuses.GetStatusByID; import org.joinmastodon.android.api.requests.statuses.GetStatusContext; -import org.joinmastodon.android.api.session.AccountSession; -import org.joinmastodon.android.api.session.AccountSessionManager; +import org.joinmastodon.android.events.StatusCountersUpdatedEvent; import org.joinmastodon.android.events.StatusCreatedEvent; +import org.joinmastodon.android.events.StatusUpdatedEvent; import org.joinmastodon.android.model.Account; import org.joinmastodon.android.model.Filter; -import org.joinmastodon.android.model.Instance; import org.joinmastodon.android.model.Status; import org.joinmastodon.android.model.StatusContext; +import org.joinmastodon.android.ui.BetterItemAnimator; import org.joinmastodon.android.ui.displayitems.ExtendedFooterStatusDisplayItem; import org.joinmastodon.android.ui.displayitems.FooterStatusDisplayItem; import org.joinmastodon.android.ui.displayitems.ReblogOrReplyLineStatusDisplayItem; @@ -40,31 +43,16 @@ import java.util.Map; import java.util.Objects; import java.util.Optional; import java.util.stream.Collectors; -import java.util.stream.Stream; +import me.grishka.appkit.api.Callback; +import me.grishka.appkit.api.ErrorResponse; import me.grishka.appkit.api.SimpleCallback; +import me.grishka.appkit.utils.V; public class ThreadFragment extends StatusListFragment implements ProvidesAssistContent { - protected Status mainStatus; - - /** - * lists the hierarchy of ancestors and descendants in a thread. level 0 = the main status. - * e.g. - *
-	 * [0] ancestor:   -2 ↰
-	 * [1] ancestor:     -1 ↰
-	 * [2] main status:     0 ↰
-	 * [3] descendant:        1 ↰
-	 * [4] descendant:          2 ↰
-	 * [5] descendant:            3
-	 * [6] descendant:        1
-	 * [7] descendant:        1 ↰
-	 * [8] descendant:          2
-	 * 
- * confused? good. /j - */ - private final List> levels = new ArrayList<>(); + protected Status mainStatus, updatedStatus; private final HashMap ancestryMap = new HashMap<>(); + protected boolean contextInitiallyRendered; @Override public void onCreate(Bundle savedInstanceState){ @@ -96,10 +84,10 @@ public class ThreadFragment extends StatusListFragment implements ProvidesAssist NeighborAncestryInfo ancestryInfo = ancestryMap.get(s.id); if (ancestryInfo != null) { item.setAncestryInfo( - ancestryInfo.hasDescendantNeighbor(), - ancestryInfo.hasAncestoringNeighbor(), + ancestryInfo.descendantNeighbor != null, + ancestryInfo.ancestoringNeighbor != null, s.id.equals(mainStatus.id), - ancestryInfo.getAncestoringNeighbor() + Optional.ofNullable(ancestryInfo.ancestoringNeighbor) .map(ancestor -> ancestor.id.equals(mainStatus.id)) .orElse(false) ); @@ -119,19 +107,23 @@ public class ThreadFragment extends StatusListFragment implements ProvidesAssist } for (int deleteThisItem : deleteTheseItems) itemsToModify.remove(deleteThisItem); if(s.id.equals(mainStatus.id)) { - items.add(new ExtendedFooterStatusDisplayItem(s.id, this, accountID, s.getContentStatus())); + items.add(new ExtendedFooterStatusDisplayItem(s.id, this, s.getContentStatus())); } return items; } @Override protected void doLoadData(int offset, int count){ + if (refreshing) loadMainStatus(); currentRequest=new GetStatusContext(mainStatus.id) .setCallback(new SimpleCallback<>(this){ @Override public void onSuccess(StatusContext result){ - if (getActivity() == null) return; + if (getContext() == null) return; + Map oldData = null; if(refreshing){ + oldData = new HashMap<>(data.size()); + for (Status s : data) oldData.put(s.id, s); data.clear(); ancestryMap.clear(); displayItems.clear(); @@ -163,17 +155,81 @@ public class ThreadFragment extends StatusListFragment implements ProvidesAssist adapter.notifyItemRemoved(prependedCount); count--; } + + for (Status s : data) { + Status oldStatus = oldData == null ? null : oldData.get(s.id); + // restore previous spoiler/filter revealed states when refreshing + if (oldStatus != null) { + s.spoilerRevealed = oldStatus.spoilerRevealed; + s.filterRevealed = oldStatus.filterRevealed; + } else if (GlobalUserPreferences.autoRevealEqualSpoilers != AutoRevealMode.NEVER && + s.spoilerText != null && + s.spoilerText.equals(mainStatus.spoilerText) && + mainStatus.spoilerRevealed) { + if (GlobalUserPreferences.autoRevealEqualSpoilers == AutoRevealMode.DISCUSSIONS || Objects.equals(mainStatus.account.id, s.account.id)) { + s.spoilerRevealed = true; + } + } + } + dataLoaded(); if(refreshing){ refreshDone(); adapter.notifyDataSetChanged(); } list.scrollToPosition(displayItems.size()-count); + + // no animation is going to happen, so proceeding to apply right now + if (data.size() == 1) { + contextInitiallyRendered = true; + // for the case that the main status has already finished loading + maybeApplyMainStatus(); + } } }) .exec(accountID); } + private void loadMainStatus() { + new GetStatusByID(mainStatus.id) + .setCallback(new Callback<>() { + @Override + public void onSuccess(Status status) { + if (getContext() == null || status == null) return; + updatedStatus = status; + // for the case that the context has already loaded (and the animation has + // already finished), falling back to applying it ourselves: + maybeApplyMainStatus(); + } + + @Override + public void onError(ErrorResponse error) {} + }).exec(accountID); + } + + protected Object maybeApplyMainStatus() { + if (updatedStatus == null || !contextInitiallyRendered) return null; + + // restore revealed states for main status because it gets updated after doLoadData + updatedStatus.filterRevealed = mainStatus.filterRevealed; + updatedStatus.spoilerRevealed = mainStatus.spoilerRevealed; + + // returning fired event object to facilitate testing + Object event; + if (updatedStatus.editedAt != null && + (mainStatus.editedAt == null || + updatedStatus.editedAt.isAfter(mainStatus.editedAt))) { + event = new StatusUpdatedEvent(updatedStatus); + } else { + event = new StatusCountersUpdatedEvent(updatedStatus); + } + + mainStatus = updatedStatus; + updatedStatus = null; + E.post(event); + return event; + } + public static List mapNeighborhoodAncestry(Status mainStatus, StatusContext context) { List ancestry = new ArrayList<>(); @@ -184,22 +240,21 @@ public class ThreadFragment extends StatusListFragment implements ProvidesAssist int count = statuses.size(); for (int index = 0; index < count; index++) { Status current = statuses.get(index); - NeighborAncestryInfo item = new NeighborAncestryInfo(current); - - item.descendantNeighbor = Optional - .ofNullable(count > index + 1 ? statuses.get(index + 1) : null) - .filter(s -> s.inReplyToId.equals(current.id)) - .orElse(null); - - item.ancestoringNeighbor = Optional.ofNullable(index > 0 ? ancestry.get(index - 1) : null) - .filter(ancestor -> ancestor - .getDescendantNeighbor() - .map(ancestorsDescendant -> ancestorsDescendant.id.equals(current.id)) - .orElse(false)) - .flatMap(NeighborAncestryInfo::getStatus) - .orElse(null); - - ancestry.add(item); + ancestry.add(new NeighborAncestryInfo( + current, + // descendant neighbor + Optional + .ofNullable(count > index + 1 ? statuses.get(index + 1) : null) + .filter(s -> s.inReplyToId.equals(current.id)) + .orElse(null), + // ancestoring neighbor + Optional.ofNullable(index > 0 ? ancestry.get(index - 1) : null) + .filter(ancestor -> Optional.ofNullable(ancestor.descendantNeighbor) + .map(ancestorsDescendant -> ancestorsDescendant.id.equals(current.id)) + .orElse(false)) + .map(a -> a.status) + .orElse(null) + )); } return ancestry; @@ -269,17 +324,28 @@ public class ThreadFragment extends StatusListFragment implements ProvidesAssist showContent(); if(!loaded) footerProgress.setVisibility(View.VISIBLE); + + list.setItemAnimator(new BetterItemAnimator() { + @Override + public void onAnimationFinished(@NonNull RecyclerView.ViewHolder viewHolder) { + super.onAnimationFinished(viewHolder); + contextInitiallyRendered = true; + // for the case that both requests are already done (and thus won't apply it) + maybeApplyMainStatus(); + } + }); } protected void onStatusCreated(StatusCreatedEvent ev){ if(ev.status.inReplyToId!=null && getStatusByID(ev.status.inReplyToId)!=null){ + data.add(ev.status); onAppendItems(Collections.singletonList(ev.status)); } } @Override public boolean isItemEnabled(String id){ - return !id.equals(mainStatus.id); + return !id.equals(mainStatus.id) || !mainStatus.filterRevealed; } @Override @@ -303,31 +369,13 @@ public class ThreadFragment extends StatusListFragment implements ProvidesAssist return Uri.parse(mainStatus.url); } - public static class NeighborAncestryInfo { + protected static class NeighborAncestryInfo { protected Status status, descendantNeighbor, ancestoringNeighbor; - public NeighborAncestryInfo(@NonNull Status status) { + protected NeighborAncestryInfo(@NonNull Status status, Status descendantNeighbor, Status ancestoringNeighbor) { this.status = status; - } - - public Optional getStatus() { - return Optional.ofNullable(status); - } - - public Optional getDescendantNeighbor() { - return Optional.ofNullable(descendantNeighbor); - } - - public Optional getAncestoringNeighbor() { - return Optional.ofNullable(ancestoringNeighbor); - } - - public boolean hasDescendantNeighbor() { - return getDescendantNeighbor().isPresent(); - } - - public boolean hasAncestoringNeighbor() { - return getAncestoringNeighbor().isPresent(); + this.descendantNeighbor = descendantNeighbor; + this.ancestoringNeighbor = ancestoringNeighbor; } @Override @@ -345,4 +393,16 @@ public class ThreadFragment extends StatusListFragment implements ProvidesAssist return Objects.hash(status, descendantNeighbor, ancestoringNeighbor); } } + + @Override + protected void onErrorRetryClick(){ + if(preloadingFailed){ + preloadingFailed=false; + V.setVisibilityAnimated(footerProgress, View.VISIBLE); + V.setVisibilityAnimated(footerError, View.GONE); + doLoadData(); + return; + } + super.onErrorRetryClick(); + } } diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/account_list/AccountRelatedAccountListFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/account_list/AccountRelatedAccountListFragment.java index 3a27bca56..43ad6ffa4 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/account_list/AccountRelatedAccountListFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/account_list/AccountRelatedAccountListFragment.java @@ -3,16 +3,27 @@ package org.joinmastodon.android.fragments.account_list; import android.net.Uri; import android.os.Bundle; +import org.joinmastodon.android.R; +import org.joinmastodon.android.api.MastodonAPIRequest; +import org.joinmastodon.android.api.requests.accounts.GetAccountByHandle; +import org.joinmastodon.android.api.session.AccountSession; +import org.joinmastodon.android.api.session.AccountSessionManager; import org.joinmastodon.android.model.Account; import org.parceler.Parcels; -public abstract class AccountRelatedAccountListFragment extends PaginatedAccountListFragment{ +import java.util.Optional; + +public abstract class AccountRelatedAccountListFragment extends PaginatedAccountListFragment { protected Account account; + protected String initialSubtitle = ""; @Override public void onCreate(Bundle savedInstanceState){ super.onCreate(savedInstanceState); account=Parcels.unwrap(getArguments().getParcelable("targetAccount")); + if (getArguments().containsKey("remoteAccount")) { + remoteInfo = Parcels.unwrap(getArguments().getParcelable("remoteAccount")); + } setTitle("@"+account.acct); } @@ -22,4 +33,36 @@ public abstract class AccountRelatedAccountListFragment extends PaginatedAccount ? "/users/" + account.id : '@' + account.acct).build(); } + + @Override + public String getRemoteDomain() { + return account.getDomainFromURL(); + } + + @Override + public Account getCurrentInfo() { + return doneWithHomeInstance && remoteInfo != null ? remoteInfo : account; + } + + @Override + protected MastodonAPIRequest loadRemoteInfo() { + return new GetAccountByHandle(account.acct); + } + + @Override + protected AccountSession getRemoteSession() { + return Optional.ofNullable(remoteInfo) + .map(AccountSessionManager.getInstance()::tryGetAccount) + .orElse(null); + } + + @Override + protected void onRemoteLoadingFailed() { + super.onRemoteLoadingFailed(); + String prefix = initialSubtitle == null ? "" : + initialSubtitle + " " + getContext().getString(R.string.sk_separator) + " "; + String str = prefix + + getContext().getString(R.string.sk_no_remote_info_hint, getSession().domain); + setSubtitle(str); + } } diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/account_list/BaseAccountListFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/account_list/BaseAccountListFragment.java index cdf0e9625..782766372 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/account_list/BaseAccountListFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/account_list/BaseAccountListFragment.java @@ -1,5 +1,6 @@ package org.joinmastodon.android.fragments.account_list; +import android.annotation.SuppressLint; import android.app.ProgressDialog; import android.app.assist.AssistContent; import android.content.Intent; @@ -47,6 +48,7 @@ import java.util.stream.Collectors; import androidx.annotation.CallSuper; import androidx.annotation.NonNull; +import androidx.annotation.Nullable; import androidx.recyclerview.widget.RecyclerView; import me.grishka.appkit.Nav; import me.grishka.appkit.api.APIRequest; @@ -82,10 +84,7 @@ public abstract class BaseAccountListFragment extends RecyclerFragment { - Bundle args=new Bundle(); - args.putString("account", accountID); - args.putParcelable("profileAccount", Parcels.wrap(account)); - Nav.go(getActivity(), ProfileFragment.class, args); - }); - return; - } Bundle args=new Bundle(); args.putString("account", accountID); - args.putParcelable("profileAccount", Parcels.wrap(item.account)); + if (item.account.isRemote) args.putParcelable("remoteAccount", Parcels.wrap(item.account)); + else args.putParcelable("profileAccount", Parcels.wrap(item.account)); Nav.go(getActivity(), ProfileFragment.class, args); } @@ -442,5 +431,10 @@ public abstract class BaseAccountListFragment extends RecyclerFragment onCreateRequest(String maxID, int count){ - return new GetAccountFollowers(account.id, maxID, count); - } - - @Override - public HeaderPaginationRequest onCreateRemoteRequest(String id, String maxID, int count) { - return new GetAccountFollowers(id, maxID, count); + return new GetAccountFollowers(getCurrentInfo().id, maxID, count); } @Override diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/account_list/FollowingListFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/account_list/FollowingListFragment.java index 6fc2b98c3..0e1c2bacb 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/account_list/FollowingListFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/account_list/FollowingListFragment.java @@ -13,18 +13,12 @@ public class FollowingListFragment extends AccountRelatedAccountListFragment{ @Override public void onCreate(Bundle savedInstanceState){ super.onCreate(savedInstanceState); - targetAccount = account; - setSubtitle(getResources().getQuantityString(R.plurals.x_following, (int)(account.followingCount%1000), account.followingCount)); + setSubtitle(initialSubtitle = getResources().getQuantityString(R.plurals.x_following, (int)(account.followingCount%1000), account.followingCount)); } @Override public HeaderPaginationRequest onCreateRequest(String maxID, int count){ - return new GetAccountFollowing(account.id, maxID, count); - } - - @Override - public HeaderPaginationRequest onCreateRemoteRequest(String id, String maxID, int count) { - return new GetAccountFollowing(id, maxID, count); + return new GetAccountFollowing(getCurrentInfo().id, maxID, count); } @Override diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/account_list/PaginatedAccountListFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/account_list/PaginatedAccountListFragment.java index 8e20cfdce..f6e77efe8 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/account_list/PaginatedAccountListFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/account_list/PaginatedAccountListFragment.java @@ -1,105 +1,173 @@ package org.joinmastodon.android.fragments.account_list; -import android.net.Uri; +import android.os.Bundle; +import android.view.View; import org.joinmastodon.android.GlobalUserPreferences; +import org.joinmastodon.android.api.MastodonAPIRequest; import org.joinmastodon.android.api.requests.HeaderPaginationRequest; -import org.joinmastodon.android.api.session.AccountSessionManager; +import org.joinmastodon.android.api.session.AccountSession; import org.joinmastodon.android.model.Account; import org.joinmastodon.android.model.HeaderPaginationList; -import org.joinmastodon.android.ui.utils.UiUtils; +import java.util.Collection; +import java.util.Collections; +import java.util.List; import java.util.stream.Collectors; import me.grishka.appkit.api.Callback; import me.grishka.appkit.api.ErrorResponse; import me.grishka.appkit.api.SimpleCallback; -public abstract class PaginatedAccountListFragment extends BaseAccountListFragment{ +public abstract class PaginatedAccountListFragment extends BaseAccountListFragment{ private String nextMaxID; - - protected Account targetAccount; - - protected Account remoteAccount; + private MastodonAPIRequest remoteInfoRequest; + protected boolean doneWithHomeInstance, remoteRequestFailed, startedRemoteLoading, remoteDisabled; + protected int localOffset; + protected T remoteInfo; public abstract HeaderPaginationRequest onCreateRequest(String maxID, int count); - public abstract HeaderPaginationRequest onCreateRemoteRequest(String id, String maxID, int count); + protected abstract MastodonAPIRequest loadRemoteInfo(); + public abstract T getCurrentInfo(); + public abstract String getRemoteDomain(); + + @Override + public void onViewCreated(View view, Bundle savedInstanceState) { + super.onViewCreated(view, savedInstanceState); + + // already have remote info (e.g. from arguments), so no need to fetch it again + if (remoteInfo != null) { + onRemoteInfoLoaded(remoteInfo); + return; + } + + remoteDisabled = !GlobalUserPreferences.allowRemoteLoading + || getSession().domain.equals(getRemoteDomain()); + if (!remoteDisabled) { + remoteInfoRequest = loadRemoteInfo().setCallback(new Callback<>() { + @Override + public void onSuccess(T result) { + if (getContext() == null) return; + onRemoteInfoLoaded(result); + } + + @Override + public void onError(ErrorResponse error) { + if (getContext() == null) return; + onRemoteLoadingFailed(); + } + }); + remoteInfoRequest.execRemote(getRemoteDomain(), getRemoteSession()); + } + } + + /** + * override to provide an ideal account session (e.g. if you're logged into the author's remote + * account) to make the remote request from. if null is provided, will try to get any session + * on the remote domain, or tries the request without authentication. + */ + protected AccountSession getRemoteSession() { + return null; + } + + protected void onRemoteInfoLoaded(T info) { + this.remoteInfo = info; + this.remoteInfoRequest = null; + maybeStartLoadingRemote(); + } + + protected void onRemoteLoadingFailed() { + this.remoteRequestFailed = true; + this.remoteInfo = null; + this.remoteInfoRequest = null; + if (doneWithHomeInstance) dataLoaded(); + } + + @Override + public void dataLoaded() { + super.dataLoaded(); + footerProgress.setVisibility(View.GONE); + } + + private void maybeStartLoadingRemote() { + if (startedRemoteLoading || remoteDisabled) return; + if (!remoteRequestFailed) { + if (data.size() == 0) showProgress(); + else footerProgress.setVisibility(View.VISIBLE); + } + if (doneWithHomeInstance && remoteInfo != null) { + startedRemoteLoading = true; + loadData(localOffset, itemsPerPage * 2); + } + } + + @Override + public void onRefresh() { + localOffset = 0; + doneWithHomeInstance = false; + startedRemoteLoading = false; + super.onRefresh(); + } + + @Override + public void loadData(int offset, int count) { + // always subtract the amount loaded through the home instance once loading from remote + // since loadData gets called with data.size() (data includes both local and remote) + if (doneWithHomeInstance) offset -= localOffset; + super.loadData(offset, count); + } @Override protected void doLoadData(int offset, int count){ - if (shouldLoadRemote()) { - if(remoteAccount == null){ - UiUtils.lookupRemoteAccount(getContext(), targetAccount, accountID, null, account -> { - remoteAccount = account; - if(remoteAccount != null){ - loadRemoteFollower(offset, count, remoteAccount); - } else { - loadFollower(offset, count); - } - }); - } else { - loadRemoteFollower(offset, count, remoteAccount); - } - } else { - loadFollower(offset, count); - } - } - - private boolean shouldLoadRemote() { - if (!GlobalUserPreferences.loadRemoteAccountFollowers && (this instanceof FollowingListFragment || this instanceof FollowerListFragment)) { - return false; - } - return targetAccount != null && targetAccount.getDomain() != null; - } - - void loadFollower(int offset, int count) { - currentRequest=onCreateRequest(offset==0 ? null : nextMaxID, count) + MastodonAPIRequest request = onCreateRequest(offset==0 ? null : nextMaxID, count) .setCallback(new SimpleCallback<>(this){ @Override public void onSuccess(HeaderPaginationList result){ - if(result.nextPageUri!=null) - nextMaxID=result.nextPageUri.getQueryParameter("max_id"); - else - nextMaxID=null; - onDataLoaded(result.stream().map(AccountItem::new).collect(Collectors.toList()), nextMaxID!=null); - } - }) - .exec(accountID); - } + boolean justRefreshed = !doneWithHomeInstance && offset == 0; + Collection d = justRefreshed ? List.of() : data; - private void loadRemoteFollower(int offset, int count, Account account) { - String ownDomain = AccountSessionManager.getInstance().getLastActiveAccount().domain; - currentRequest=onCreateRemoteRequest(account.id, offset==0 ? null : nextMaxID, count) - .setCallback(new Callback<>(){ - @Override - public void onSuccess(HeaderPaginationList result){ if(result.nextPageUri!=null) nextMaxID=result.nextPageUri.getQueryParameter("max_id"); else nextMaxID=null; - result.stream().forEach(remoteAccount -> { - remoteAccount.reloadWhenClicked = true; - if (remoteAccount.getDomain() == null) { - remoteAccount.acct += "@" + Uri.parse(remoteAccount.url).getHost(); - } else if (remoteAccount.getDomain().equals(ownDomain)) { - remoteAccount.acct = remoteAccount.username; - } - }); - if(!result.isEmpty()){ - onDataLoaded(result.stream().map(AccountItem::new).collect(Collectors.toList()), nextMaxID!=null); - } else { - loadFollower(offset, count); + if (getActivity() == null) return; + List items = result.stream() + .filter(a -> d.size() > 1000 || d.stream() + .noneMatch(i -> i.account.url.equals(a.url))) + .map(AccountItem::new) + .collect(Collectors.toList()); + + boolean hasMore = nextMaxID != null; + + if (!hasMore && !doneWithHomeInstance) { + // only runs last time data was fetched from the home instance + localOffset = d.size() + items.size(); + doneWithHomeInstance = true; } + + onDataLoaded(items, hasMore); + if (doneWithHomeInstance) maybeStartLoadingRemote(); } @Override public void onError(ErrorResponse error) { - error.showToast(getContext()); - loadFollower(offset, count); + if (doneWithHomeInstance) { + onRemoteLoadingFailed(); + onDataLoaded(Collections.emptyList(), false); + return; + } + super.onError(error); } - }) - .execNoAuth(targetAccount.getDomain()); + }); + + if (doneWithHomeInstance && remoteInfo == null) return; // we are waiting + if (doneWithHomeInstance && remoteInfo != null) { + request.execRemote(getRemoteDomain(), getRemoteSession()); + } else { + request.exec(accountID); + } + currentRequest = request; } @Override diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/account_list/StatusFavoritesListFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/account_list/StatusFavoritesListFragment.java index 55629e8cf..627bd4ad8 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/account_list/StatusFavoritesListFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/account_list/StatusFavoritesListFragment.java @@ -7,17 +7,23 @@ import org.joinmastodon.android.R; import org.joinmastodon.android.api.requests.HeaderPaginationRequest; import org.joinmastodon.android.api.requests.statuses.GetStatusFavorites; import org.joinmastodon.android.model.Account; +import org.joinmastodon.android.model.Status; public class StatusFavoritesListFragment extends StatusRelatedAccountListFragment{ @Override public void onCreate(Bundle savedInstanceState){ super.onCreate(savedInstanceState); + updateTitle(status); + } + + @Override + protected void updateTitle(Status status) { setTitle(getResources().getQuantityString(R.plurals.x_favorites, (int)(status.favouritesCount%1000), status.favouritesCount)); } @Override public HeaderPaginationRequest onCreateRequest(String maxID, int count){ - return new GetStatusFavorites(status.id, maxID, count); + return new GetStatusFavorites(getCurrentInfo().id, maxID, count); } @Override diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/account_list/StatusReblogsListFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/account_list/StatusReblogsListFragment.java index 3505df53a..608e34d08 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/account_list/StatusReblogsListFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/account_list/StatusReblogsListFragment.java @@ -7,17 +7,23 @@ import org.joinmastodon.android.R; import org.joinmastodon.android.api.requests.HeaderPaginationRequest; import org.joinmastodon.android.api.requests.statuses.GetStatusReblogs; import org.joinmastodon.android.model.Account; +import org.joinmastodon.android.model.Status; public class StatusReblogsListFragment extends StatusRelatedAccountListFragment{ @Override public void onCreate(Bundle savedInstanceState){ super.onCreate(savedInstanceState); + updateTitle(status); + } + + @Override + protected void updateTitle(Status status) { setTitle(getResources().getQuantityString(R.plurals.x_reblogs, (int)(status.reblogsCount%1000), status.reblogsCount)); } @Override public HeaderPaginationRequest onCreateRequest(String maxID, int count){ - return new GetStatusReblogs(status.id, maxID, count); + return new GetStatusReblogs(getCurrentInfo().id, maxID, count); } @Override diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/account_list/StatusRelatedAccountListFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/account_list/StatusRelatedAccountListFragment.java index d0499afa6..aeee1fdc9 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/account_list/StatusRelatedAccountListFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/account_list/StatusRelatedAccountListFragment.java @@ -3,12 +3,27 @@ package org.joinmastodon.android.fragments.account_list; import android.net.Uri; import android.os.Bundle; +import org.joinmastodon.android.R; +import org.joinmastodon.android.api.MastodonAPIRequest; +import org.joinmastodon.android.api.requests.statuses.GetStatusByID; +import org.joinmastodon.android.api.session.AccountSession; +import org.joinmastodon.android.api.session.AccountSessionManager; import org.joinmastodon.android.model.Status; import org.parceler.Parcels; -public abstract class StatusRelatedAccountListFragment extends PaginatedAccountListFragment{ +import java.util.Optional; + +public abstract class StatusRelatedAccountListFragment extends PaginatedAccountListFragment { protected Status status; + protected abstract void updateTitle(Status status); + + protected MastodonAPIRequest loadRemoteInfo() { + String[] parts = status.url.split("/"); + if (parts.length == 0) return null; + return new GetStatusByID(parts[parts.length - 1]); + } + @Override public void onCreate(Bundle savedInstanceState){ super.onCreate(savedInstanceState); @@ -17,7 +32,7 @@ public abstract class StatusRelatedAccountListFragment extends PaginatedAccountL @Override protected boolean hasSubtitle(){ - return false; + return remoteRequestFailed; } @Override @@ -28,4 +43,35 @@ public abstract class StatusRelatedAccountListFragment extends PaginatedAccountL : '@' + status.account.acct + '/' + status.id) .build(); } + + @Override + public String getRemoteDomain() { + return Uri.parse(status.url).getHost(); + } + + @Override + public Status getCurrentInfo() { + return doneWithHomeInstance && remoteInfo != null ? remoteInfo : status; + } + + @Override + protected AccountSession getRemoteSession() { + return Optional.ofNullable(remoteInfo) + .map(s -> s.account) + .map(AccountSessionManager.getInstance()::tryGetAccount) + .orElse(null); + } + + @Override + protected void onRemoteInfoLoaded(Status info) { + super.onRemoteInfoLoaded(info); + updateTitle(remoteInfo); + } + + @Override + protected void onRemoteLoadingFailed() { + super.onRemoteLoadingFailed(); + setSubtitle(getContext().getString(R.string.sk_no_remote_info_hint, getSession().domain)); + updateToolbar(); + } } diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/discover/DiscoverPostsFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/discover/DiscoverPostsFragment.java index 67596593a..e2f14892f 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/discover/DiscoverPostsFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/discover/DiscoverPostsFragment.java @@ -5,7 +5,6 @@ import android.os.Bundle; import android.view.View; import org.joinmastodon.android.api.requests.trends.GetTrendingStatuses; -import org.joinmastodon.android.fragments.IsOnTop; import org.joinmastodon.android.fragments.StatusListFragment; import org.joinmastodon.android.model.Filter; import org.joinmastodon.android.model.Status; @@ -17,7 +16,7 @@ import java.util.stream.Collectors; import me.grishka.appkit.api.SimpleCallback; -public class DiscoverPostsFragment extends StatusListFragment implements IsOnTop { +public class DiscoverPostsFragment extends StatusListFragment { private DiscoverInfoBannerHelper bannerHelper=new DiscoverInfoBannerHelper(DiscoverInfoBannerHelper.BannerType.TRENDING_POSTS); @Override @@ -44,11 +43,6 @@ public class DiscoverPostsFragment extends StatusListFragment implements IsOnTop bannerHelper.maybeAddBanner(contentWrap); } - @Override - public boolean isOnTop() { - return isRecyclerViewOnTop(list); - } - @Override protected Filter.FilterContext getFilterContext() { return Filter.FilterContext.PUBLIC; diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/discover/SearchFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/discover/SearchFragment.java index aa4b030b5..616a58723 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/discover/SearchFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/discover/SearchFragment.java @@ -12,7 +12,6 @@ import org.joinmastodon.android.R; import org.joinmastodon.android.api.requests.search.GetSearchResults; import org.joinmastodon.android.api.session.AccountSessionManager; import org.joinmastodon.android.fragments.BaseStatusListFragment; -import org.joinmastodon.android.fragments.IsOnTop; import org.joinmastodon.android.fragments.ProfileFragment; import org.joinmastodon.android.fragments.ThreadFragment; import org.joinmastodon.android.model.Account; @@ -44,7 +43,7 @@ import me.grishka.appkit.api.SimpleCallback; import me.grishka.appkit.utils.MergeRecyclerAdapter; import me.grishka.appkit.utils.V; -public class SearchFragment extends BaseStatusListFragment implements IsOnTop { +public class SearchFragment extends BaseStatusListFragment { private String currentQuery; private List prevDisplayItems; private EnumSet currentFilter=EnumSet.allOf(SearchResult.Type.class); @@ -313,11 +312,6 @@ public class SearchFragment extends BaseStatusListFragment impleme } } - @Override - public boolean isOnTop() { - return isRecyclerViewOnTop(list); - } - @Override public Uri getWebUri(Uri.Builder base) { Uri.Builder searchUri = base.path("/search"); diff --git a/mastodon/src/main/java/org/joinmastodon/android/model/Account.java b/mastodon/src/main/java/org/joinmastodon/android/model/Account.java index 1261a45f4..c11c87507 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/model/Account.java +++ b/mastodon/src/main/java/org/joinmastodon/android/model/Account.java @@ -1,7 +1,10 @@ package org.joinmastodon.android.model; +import android.net.Uri; import android.text.TextUtils; +import androidx.annotation.Nullable; + import org.joinmastodon.android.api.ObjectValidationException; import org.joinmastodon.android.api.RequiredField; import org.parceler.Parcel; @@ -48,7 +51,7 @@ public class Account extends BaseModel implements Searchable{ /** * The profile's bio / description. */ - + public String note; /** * An image icon that is shown next to statuses and in the profile. @@ -62,7 +65,6 @@ public class Account extends BaseModel implements Searchable{ /** * An image banner that is shown above the profile and in profile cards. */ -// @RequiredField public String header; /** * A static version of the header. Equal to header if its value is a static image; different if header is an animated GIF. @@ -136,7 +138,9 @@ public class Account extends BaseModel implements Searchable{ public List roles; public boolean reloadWhenClicked; - @Override + public @Nullable String fqn; // akkoma has this, mastodon't + + @Override public String getQuery() { return url; } @@ -163,6 +167,7 @@ public class Account extends BaseModel implements Searchable{ moved.postprocess(); if(TextUtils.isEmpty(displayName)) displayName=username; + if(fqn == null) fqn = getFullyQualifiedName(); } public boolean isLocal(){ @@ -174,6 +179,10 @@ public class Account extends BaseModel implements Searchable{ return parts.length==1 ? null : parts[1]; } + public String getDomainFromURL() { + return Uri.parse(url).getHost(); + } + public String getDisplayUsername(){ return '@'+acct; } @@ -182,6 +191,10 @@ public class Account extends BaseModel implements Searchable{ return '@'+acct.split("@")[0]; } + public String getFullyQualifiedName() { + return fqn != null ? fqn : acct.split("@")[0] + "@" + getDomainFromURL(); + } + @Override public String toString(){ return "Account{"+ diff --git a/mastodon/src/main/java/org/joinmastodon/android/model/BaseModel.java b/mastodon/src/main/java/org/joinmastodon/android/model/BaseModel.java index e68026c9e..051be6240 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/model/BaseModel.java +++ b/mastodon/src/main/java/org/joinmastodon/android/model/BaseModel.java @@ -8,8 +8,17 @@ import java.lang.reflect.Field; import java.lang.reflect.Modifier; import androidx.annotation.CallSuper; +import androidx.annotation.NonNull; + +public abstract class BaseModel implements Cloneable{ + + /** + * indicates the profile has been fetched from a foreign instance. + * + * @see MastodonAPIRequest#execRemote + */ + public transient boolean isRemote; -public abstract class BaseModel{ @CallSuper public void postprocess() throws ObjectValidationException{ try{ @@ -23,4 +32,14 @@ public abstract class BaseModel{ } }catch(IllegalAccessException ignore){} } + + @NonNull + @Override + public Object clone(){ + try{ + return super.clone(); + }catch(CloneNotSupportedException x){ + throw new RuntimeException(x); + } + } } diff --git a/mastodon/src/main/java/org/joinmastodon/android/model/Instance.java b/mastodon/src/main/java/org/joinmastodon/android/model/Instance.java index 12707952f..e5b6f8719 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/model/Instance.java +++ b/mastodon/src/main/java/org/joinmastodon/android/model/Instance.java @@ -148,6 +148,10 @@ public class Instance extends BaseModel{ return pleroma != null; } + public boolean isPixelfed() { + return version.contains("compatible; Pixelfed"); + } + public boolean hasFeature(Feature feature) { Optional> pleromaFeatures = Optional.ofNullable(pleroma) .map(p -> p.metadata) diff --git a/mastodon/src/main/java/org/joinmastodon/android/model/PushSubscription.java b/mastodon/src/main/java/org/joinmastodon/android/model/PushSubscription.java index 7d9edd1d4..f3c4f4fa8 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/model/PushSubscription.java +++ b/mastodon/src/main/java/org/joinmastodon/android/model/PushSubscription.java @@ -30,10 +30,7 @@ public class PushSubscription extends BaseModel implements Cloneable{ @NonNull @Override public PushSubscription clone(){ - PushSubscription copy=null; - try{ - copy=(PushSubscription) super.clone(); - }catch(CloneNotSupportedException ignore){} + PushSubscription copy=(PushSubscription) super.clone(); copy.alerts=alerts.clone(); return copy; } diff --git a/mastodon/src/main/java/org/joinmastodon/android/model/ScheduledStatus.java b/mastodon/src/main/java/org/joinmastodon/android/model/ScheduledStatus.java index ebc56c8c7..309bb4646 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/model/ScheduledStatus.java +++ b/mastodon/src/main/java/org/joinmastodon/android/model/ScheduledStatus.java @@ -1,5 +1,6 @@ package org.joinmastodon.android.model; +import org.joinmastodon.android.api.ObjectValidationException; import org.joinmastodon.android.api.RequiredField; import org.joinmastodon.android.model.Poll.Option; import org.parceler.Parcel; @@ -16,7 +17,6 @@ public class ScheduledStatus extends BaseModel implements DisplayItemsParent{ public Instant scheduledAt; @RequiredField public Params params; - @RequiredField public List mediaAttachments; @Override @@ -24,8 +24,17 @@ public class ScheduledStatus extends BaseModel implements DisplayItemsParent{ return id; } + @Override + public void postprocess() throws ObjectValidationException { + super.postprocess(); + if (mediaAttachments == null) mediaAttachments = List.of(); + for(Attachment a:mediaAttachments) + a.postprocess(); + if (params != null) params.postprocess(); + } + @Parcel - public static class Params { + public static class Params extends BaseModel { @RequiredField public String text; public String spoilerText; @@ -40,10 +49,16 @@ public class ScheduledStatus extends BaseModel implements DisplayItemsParent{ public String applicationId; public List mediaIds; public ContentType contentType; + + @Override + public void postprocess() throws ObjectValidationException { + super.postprocess(); + if (poll != null) poll.postprocess(); + } } @Parcel - public static class ScheduledPoll { + public static class ScheduledPoll extends BaseModel { @RequiredField public String expiresIn; @RequiredField diff --git a/mastodon/src/main/java/org/joinmastodon/android/model/Status.java b/mastodon/src/main/java/org/joinmastodon/android/model/Status.java index f74e45f23..55953e052 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/model/Status.java +++ b/mastodon/src/main/java/org/joinmastodon/android/model/Status.java @@ -23,6 +23,8 @@ import java.lang.reflect.Type; import java.time.Instant; import java.util.List; +import androidx.annotation.NonNull; + @Parcel public class Status extends BaseModel implements DisplayItemsParent, Searchable{ @RequiredField @@ -40,7 +42,6 @@ public class Status extends BaseModel implements DisplayItemsParent, Searchable{ public boolean sensitive; @RequiredField public String spoilerText; - @RequiredField public List mediaAttachments; public Application application; @RequiredField @@ -100,6 +101,7 @@ public class Status extends BaseModel implements DisplayItemsParent, Searchable{ t.postprocess(); for(Emoji e:emojis) e.postprocess(); + if (mediaAttachments == null) mediaAttachments = List.of(); for(Attachment a:mediaAttachments) a.postprocess(); account.postprocess(); @@ -202,7 +204,6 @@ public class Status extends BaseModel implements DisplayItemsParent, Searchable{ } public static class StatusDeserializer implements JsonDeserializer { - @Override public Status deserialize(JsonElement json, Type typeOfT, JsonDeserializationContext context) throws JsonParseException { JsonObject obj = json.getAsJsonObject(); diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/AccountSwitcherSheet.java b/mastodon/src/main/java/org/joinmastodon/android/ui/AccountSwitcherSheet.java index 8cc5d32e7..319b43896 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/ui/AccountSwitcherSheet.java +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/AccountSwitcherSheet.java @@ -58,25 +58,24 @@ import me.grishka.appkit.views.UsableRecyclerView; public class AccountSwitcherSheet extends BottomSheet{ private final Activity activity; private final HomeFragment fragment; - private final BiConsumer onClick; private final boolean externalShare, openInApp; + private BiConsumer onClick; private UsableRecyclerView list; private List accounts; private ListImageLoaderWrapper imgLoader; private AccountsAdapter accountsAdapter; public AccountSwitcherSheet(@NonNull Activity activity, @Nullable HomeFragment fragment){ - this(activity, fragment, false, false, null); + this(activity, fragment, false, false); } - public AccountSwitcherSheet(@NonNull Activity activity, @Nullable HomeFragment fragment, boolean externalShare, boolean openInApp, BiConsumer onClick){ + public AccountSwitcherSheet(@NonNull Activity activity, @Nullable HomeFragment fragment, boolean externalShare, boolean openInApp){ super(activity); this.activity=activity; this.fragment=fragment; this.externalShare = externalShare; this.openInApp = openInApp; - this.onClick = onClick; - + accounts=AccountSessionManager.getInstance().getLoggedInAccounts().stream().map(WrappedAccount::new).collect(Collectors.toList()); list=new UsableRecyclerView(activity); @@ -122,6 +121,10 @@ public class AccountSwitcherSheet extends BottomSheet{ setNavigationBarBackground(new ColorDrawable(UiUtils.getThemeColor(getContext(), R.attr.colorWindowBackground)), !UiUtils.isDarkTheme()); } + public void setOnClick(BiConsumer onClick) { + this.onClick = onClick; + } + private void confirmLogOut(String accountID){ AccountSession session=AccountSessionManager.getInstance().getAccount(accountID); new M3AlertDialogBuilder(activity) diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/FooterStatusDisplayItem.java b/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/FooterStatusDisplayItem.java index 1027d1b31..7f3ccad22 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/FooterStatusDisplayItem.java +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/FooterStatusDisplayItem.java @@ -5,7 +5,6 @@ import android.app.Dialog; import android.content.Context; import android.content.Intent; import android.graphics.drawable.Drawable; -import android.os.Build; import android.os.Bundle; import android.view.LayoutInflater; import android.view.MotionEvent; @@ -20,7 +19,6 @@ import android.view.animation.BounceInterpolator; import android.view.animation.RotateAnimation; import android.widget.Button; import android.widget.FrameLayout; -import android.widget.ImageView; import android.widget.TextView; import org.joinmastodon.android.GlobalUserPreferences; @@ -59,8 +57,8 @@ public class FooterStatusDisplayItem extends StatusDisplayItem{ } public static class Holder extends StatusDisplayItem.Holder{ - private final TextView reply, boost, favorite, bookmark; - private final ImageView share; + private final TextView replies, boosts, favorites; + private final View reply, boost, favorite, share, bookmark; private static final Animation opacityOut, opacityIn; private static AnimationSet animSet; @@ -105,22 +103,16 @@ public class FooterStatusDisplayItem extends StatusDisplayItem{ public Holder(Activity activity, ViewGroup parent){ super(activity, R.layout.display_item_footer, parent); - reply=findViewById(R.id.reply); - boost=findViewById(R.id.boost); - favorite=findViewById(R.id.favorite); - bookmark=findViewById(R.id.bookmark); - share=findViewById(R.id.share); - if(Build.VERSION.SDK_INT0 && !item.hideCounts){ btn.setText(UiUtils.abbreviateNumber(count)); btn.setCompoundDrawablePadding(V.dp(8)); @@ -195,8 +187,9 @@ public class FooterStatusDisplayItem extends StatusDisplayItem{ } else if (action == MotionEvent.ACTION_DOWN) { longClickPerformed = false; touchingView = v; - // 20dp to center in middle of icon, because: (icon width = 24dp) / 2 + (paddingStart = 8dp) - v.setPivotX(V.dp(20)); + // 28dp to center in middle of icon, because: + // (icon width = 24dp) / 2 + (paddingStart = 8dp) + (paddingHorizontal = 8dp) + v.setPivotX(UiUtils.sp(v.getContext(), 28)); v.animate().scaleX(0.85f).scaleY(0.85f).setInterpolator(CubicBezierInterpolator.DEFAULT).setDuration(75).start(); if (disabled) return true; v.postDelayed(longClickRunnable, ViewConfiguration.getLongPressTimeout()); @@ -265,7 +258,7 @@ public class FooterStatusDisplayItem extends StatusDisplayItem{ private void boostConsumer(View v, Status r) { v.startAnimation(opacityIn); - bindButton(boost, r.reblogsCount); + bindText(boosts, r.reblogsCount); } private boolean onBoostLongClick(View v){ diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/HeaderStatusDisplayItem.java b/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/HeaderStatusDisplayItem.java index 5a67bd195..b8776a65d 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/HeaderStatusDisplayItem.java +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/HeaderStatusDisplayItem.java @@ -202,7 +202,14 @@ public class HeaderStatusDisplayItem extends StatusDisplayItem{ args.putBoolean("navigateToStatus", true); } } - if(!redraft && TextUtils.isEmpty(item.status.content) && TextUtils.isEmpty(item.status.spoilerText)){ + boolean isPixelfed = item.parentFragment.isInstancePixelfed(); + boolean textEmpty = TextUtils.isEmpty(item.status.content) && TextUtils.isEmpty(item.status.spoilerText); + if(!redraft && (isPixelfed || textEmpty)){ + // pixelfed doesn't support /statuses/:id/source :/ + if (isPixelfed) { + args.putString("sourceText", HtmlParser.text(item.status.content)); + args.putString("sourceSpoiler", item.status.spoilerText); + } Nav.go(item.parentFragment.getActivity(), ComposeFragment.class, args); }else if(item.scheduledStatus!=null){ args.putString("sourceText", item.status.text); diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/ReblogOrReplyLineStatusDisplayItem.java b/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/ReblogOrReplyLineStatusDisplayItem.java index 696944029..03ac49430 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/ReblogOrReplyLineStatusDisplayItem.java +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/ReblogOrReplyLineStatusDisplayItem.java @@ -73,9 +73,9 @@ public class ReblogOrReplyLineStatusDisplayItem extends StatusDisplayItem{ public void updateVisibility(StatusPrivacy visibility) { this.visibility = visibility; this.iconEnd = visibility != null ? switch (visibility) { - case PUBLIC -> R.drawable.ic_fluent_earth_20_regular; - case UNLISTED -> R.drawable.ic_fluent_lock_open_20_regular; - case PRIVATE -> R.drawable.ic_fluent_lock_closed_20_filled; + case PUBLIC -> R.drawable.ic_fluent_earth_20sp_regular; + case UNLISTED -> R.drawable.ic_fluent_lock_open_20sp_regular; + case PRIVATE -> R.drawable.ic_fluent_lock_closed_20sp_filled; default -> 0; } : 0; } diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/StatusDisplayItem.java b/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/StatusDisplayItem.java index 874629811..9795ca26b 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/StatusDisplayItem.java +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/StatusDisplayItem.java @@ -129,7 +129,7 @@ public abstract class StatusDisplayItem{ : fragment.getString(R.string.in_reply_to, account.displayName); replyLine = new ReblogOrReplyLineStatusDisplayItem( parentID, fragment, text, account == null ? List.of() : account.emojis, - R.drawable.ic_fluent_arrow_reply_20_filled, null, null, fullText + R.drawable.ic_fluent_arrow_reply_20sp_filled, null, null, fullText ); } @@ -138,7 +138,7 @@ public abstract class StatusDisplayItem{ statusForContent.rebloggedBy = status.account; String fullText = fragment.getString(R.string.user_boosted, status.account.displayName); String text = GlobalUserPreferences.compactReblogReplyLine && replyLine != null ? status.account.displayName : fullText; - items.add(new ReblogOrReplyLineStatusDisplayItem(parentID, fragment, text, status.account.emojis, R.drawable.ic_fluent_arrow_repeat_all_20_filled, isOwnPost ? status.visibility : null, i->{ + items.add(new ReblogOrReplyLineStatusDisplayItem(parentID, fragment, text, status.account.emojis, R.drawable.ic_fluent_arrow_repeat_all_20sp_filled, isOwnPost ? status.visibility : null, i->{ args.putParcelable("profileAccount", Parcels.wrap(status.account)); Nav.go(fragment.getActivity(), ProfileFragment.class, args); }, fullText)); @@ -153,7 +153,7 @@ public abstract class StatusDisplayItem{ // post contains a hashtag the user is following .ifPresent(hashtag -> items.add(new ReblogOrReplyLineStatusDisplayItem( parentID, fragment, hashtag.name, List.of(), - R.drawable.ic_fluent_number_symbol_20_filled, null, + R.drawable.ic_fluent_number_symbol_20sp_filled, null, i -> { args.putString("hashtag", hashtag.name); Nav.go(fragment.getActivity(), HashtagTimelineFragment.class, args); diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/TextStatusDisplayItem.java b/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/TextStatusDisplayItem.java index 59d026924..1291832b6 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/TextStatusDisplayItem.java +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/TextStatusDisplayItem.java @@ -65,7 +65,6 @@ public class TextStatusDisplayItem extends StatusDisplayItem{ spoilerEmojiHelper.setText(parsedSpoilerText); } session = AccountSessionManager.getInstance().getAccount(parentFragment.getAccountID()); - UiUtils.loadMaxWidth(parentFragment.getContext()); } public void setTranslationShown(boolean translationShown) { @@ -227,13 +226,13 @@ public class TextStatusDisplayItem extends StatusDisplayItem{ spaceBelowText.setVisibility(translateVisible ? View.VISIBLE : View.GONE); // remove additional padding when (transparently padded) translate button is visible - int pos = getAbsoluteAdapterPosition(); - itemView.setPadding(itemView.getPaddingLeft(), itemView.getPaddingTop(), itemView.getPaddingRight(), - (translateVisible && - item.parentFragment.getDisplayItems().size() >= pos + 1 && - item.parentFragment.getDisplayItems().get(pos + 1) instanceof FooterStatusDisplayItem) - ? 0 : V.dp(12) - ); + int nextPos = getAbsoluteAdapterPosition() + 1; + boolean nextIsFooter = item.parentFragment.getDisplayItems().size() > nextPos && + item.parentFragment.getDisplayItems().get(nextPos) instanceof FooterStatusDisplayItem; + int bottomPadding = (translateVisible && nextIsFooter) ? 0 + : nextIsFooter ? V.dp(6) + : V.dp(12); + itemView.setPadding(itemView.getPaddingLeft(), itemView.getPaddingTop(), itemView.getPaddingRight(), bottomPadding); if (!GlobalUserPreferences.collapseLongPosts) { textScrollView.setLayoutParams(wrapParams); diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/text/ClickableLinksDelegate.java b/mastodon/src/main/java/org/joinmastodon/android/ui/text/ClickableLinksDelegate.java index 15ba797b0..46f955d6b 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/ui/text/ClickableLinksDelegate.java +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/text/ClickableLinksDelegate.java @@ -4,128 +4,121 @@ import android.graphics.Canvas; import android.graphics.CornerPathEffect; import android.graphics.Paint; import android.graphics.Path; -import android.graphics.Rect; -import android.graphics.RectF; +import android.os.Build; import android.text.Layout; import android.text.Spanned; +import android.view.GestureDetector; import android.view.MotionEvent; import android.view.SoundEffectConstants; -import android.view.View; -import android.view.ViewConfiguration; import android.widget.TextView; +import androidx.annotation.NonNull; + +import org.joinmastodon.android.ui.utils.UiUtils; + import me.grishka.appkit.utils.V; public class ClickableLinksDelegate { - private Paint hlPaint; + private final Paint hlPaint; private Path hlPath; private LinkSpan selectedSpan; - private TextView view; - - private final Runnable longClickRunnable = () -> { - if (selectedSpan != null) selectedSpan.onLongClick(view); - }; + private final TextView view; + private final GestureDetector gestureDetector; public ClickableLinksDelegate(TextView view) { this.view=view; hlPaint=new Paint(); hlPaint.setAntiAlias(true); hlPaint.setPathEffect(new CornerPathEffect(V.dp(3))); -// view.setHighlightColor(view.getResources().getColor(android.R.color.holo_blue_light)); + hlPaint.setStyle(Paint.Style.FILL_AND_STROKE); + hlPaint.setStrokeWidth(V.dp(4)); + gestureDetector = new GestureDetector(view.getContext(), new LinkGestureListener(), view.getHandler()); } public boolean onTouch(MotionEvent event) { - long eventDuration = event.getEventTime() - event.getDownTime(); - if(event.getAction()==MotionEvent.ACTION_DOWN){ - int line=-1; - Rect rect=new Rect(); - Layout l=view.getLayout(); - for(int i=0;i0){ - for(LinkSpan span:spans){ - int start=s.getSpanStart(span); - int end=s.getSpanEnd(span); - int lstart=l.getLineForOffset(start); - int lend=l.getLineForOffset(end); - if(line>=lstart && line<=lend){ - if(line==lstart && event.getX()-view.getPaddingLeft()l.getPrimaryHorizontal(end)){ - continue; - } - hlPath=new Path(); - selectedSpan=span; - view.postDelayed(longClickRunnable, ViewConfiguration.getLongPressTimeout()); - hlPaint.setColor((span.getColor() & 0x00FFFFFF) | 0x33000000); - //l.getSelectionPath(start, end, hlPath); - for(int j=lstart;j<=lend;j++){ - Rect bounds=new Rect(); - l.getLineBounds(j, bounds); - //bounds.left+=view.getPaddingLeft(); - if(j==lstart){ - bounds.left=Math.round(l.getPrimaryHorizontal(start)); - } - if(j==lend){ - bounds.right=Math.round(l.getPrimaryHorizontal(end)); - }else{ - CharSequence lineChars=view.getText().subSequence(l.getLineStart(j), l.getLineEnd(j)); - bounds.right=Math.round(view.getPaint().measureText(lineChars.toString()))/*+view.getPaddingRight()*/; - } - bounds.inset(V.dp(-2), V.dp(-2)); - hlPath.addRect(new RectF(bounds), Path.Direction.CW); - } - hlPath.offset(view.getPaddingLeft(), 0); - view.invalidate(); - return true; - } - } - } - } - } - if(event.getAction()==MotionEvent.ACTION_UP && selectedSpan!=null){ - if (eventDuration <= ViewConfiguration.getLongPressTimeout()) { - view.playSoundEffect(SoundEffectConstants.CLICK); - selectedSpan.onClick(view.getContext()); - } - view.removeCallbacks(longClickRunnable); - hlPath=null; - selectedSpan=null; - view.invalidate(); - return false; - } if(event.getAction()==MotionEvent.ACTION_CANCEL){ - hlPath=null; - selectedSpan=null; - view.removeCallbacks(longClickRunnable); - view.invalidate(); - return false; + // the gestureDetector does not provide a callback for CANCEL, therefore: + // remove background color of view before passing event to gestureDetector + resetAndInvalidate(); } - return false; + return gestureDetector.onTouchEvent(event); } - + + /** + * remove highlighting from span and let the system redraw the view + */ + private void resetAndInvalidate() { + hlPath=null; + selectedSpan=null; + view.invalidate(); + } + public void onDraw(Canvas canvas){ if(hlPath!=null){ canvas.save(); - canvas.translate(0, view.getPaddingTop()); + canvas.translate(view.getTotalPaddingLeft(), view.getTotalPaddingTop()); canvas.drawPath(hlPath, hlPaint); canvas.restore(); } } + /** + * GestureListener for spans that represent URLs. + * onDown: on start of touch event, set highlighting + * onSingleTapUp: when there was a (short) tap, call onClick and reset highlighting + * onLongPress: copy URL to clipboard, let user know, reset highlighting + */ + private class LinkGestureListener extends GestureDetector.SimpleOnGestureListener { + @Override + public boolean onDown(@NonNull MotionEvent event) { + int padLeft=view.getTotalPaddingLeft(), padRight=view.getTotalPaddingRight(), padTop=view.getTotalPaddingTop(), padBottom=view.getTotalPaddingBottom(); + float x=event.getX(), y=event.getY(); + if(xview.getWidth()-padRight || y>view.getHeight()-padBottom) + return false; + x-=padLeft; + y-=padTop; + Layout l=view.getLayout(); + int line=l.getLineForVertical(Math.round(y)); + int position=l.getOffsetForHorizontal(line, x); + + CharSequence text=view.getText(); + if(text instanceof Spanned s){ + LinkSpan[] spans=s.getSpans(0, s.length()-1, LinkSpan.class); + for(LinkSpan span:spans){ + int start=s.getSpanStart(span); + int end=s.getSpanEnd(span); + if(start<=position && end>position){ + selectedSpan=span; + hlPath=new Path(); + l.getSelectionPath(start, end, hlPath); + hlPaint.setColor((span.getColor() & 0x00FFFFFF) | 0x33000000); + view.invalidate(); + return true; + } + } + } + return super.onDown(event); + } + + @Override + public boolean onSingleTapUp(@NonNull MotionEvent event) { + if(selectedSpan!=null){ + view.playSoundEffect(SoundEffectConstants.CLICK); + selectedSpan.onClick(view.getContext()); + resetAndInvalidate(); + return true; + } + return false; + } + + @Override + public void onLongPress(@NonNull MotionEvent event) { + if (selectedSpan == null) return; + UiUtils.copyText(view, selectedSpan.getType() == LinkSpan.Type.URL ? selectedSpan.getLink() : selectedSpan.getText()); + //reset view + resetAndInvalidate(); + } + } } diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/text/HtmlParser.java b/mastodon/src/main/java/org/joinmastodon/android/ui/text/HtmlParser.java index 27e7ec885..450600e51 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/ui/text/HtmlParser.java +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/text/HtmlParser.java @@ -1,14 +1,10 @@ package org.joinmastodon.android.ui.text; import android.graphics.Typeface; -import android.graphics.fonts.FontFamily; -import android.graphics.fonts.FontStyle; import android.text.SpannableStringBuilder; import android.text.Spanned; import android.text.TextUtils; -import android.text.style.BackgroundColorSpan; import android.text.style.BulletSpan; -import android.text.style.ForegroundColorSpan; import android.text.style.LeadingMarginSpan; import android.text.style.RelativeSizeSpan; import android.text.style.StrikethroughSpan; @@ -17,13 +13,10 @@ import android.text.style.SubscriptSpan; import android.text.style.SuperscriptSpan; import android.text.style.TypefaceSpan; import android.text.style.UnderlineSpan; -import android.util.TypedValue; import android.widget.TextView; import com.twitter.twittertext.Regex; -import org.joinmastodon.android.MastodonApp; -import org.joinmastodon.android.R; import org.joinmastodon.android.model.Emoji; import org.joinmastodon.android.model.Hashtag; import org.joinmastodon.android.model.Mention; @@ -251,6 +244,10 @@ public class HtmlParser{ return Jsoup.clean(html, Safelist.none()); } + public static String text(String html) { + return Jsoup.parse(html).body().wholeText(); + } + public static CharSequence parseLinks(String text){ Matcher matcher=URL_PATTERN.matcher(text); if(!matcher.find()) // Return the original string if there are no URLs diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/utils/UiUtils.java b/mastodon/src/main/java/org/joinmastodon/android/ui/utils/UiUtils.java index 58b0da6c8..cce7b9427 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/ui/utils/UiUtils.java +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/utils/UiUtils.java @@ -17,6 +17,7 @@ import android.content.DialogInterface; import android.content.Intent; import android.content.res.ColorStateList; import android.content.res.Configuration; +import android.content.res.Resources; import android.content.res.TypedArray; import android.database.Cursor; import android.graphics.Bitmap; @@ -37,6 +38,7 @@ import android.text.SpannableStringBuilder; import android.text.Spanned; import android.text.TextUtils; import android.util.Log; +import android.util.Pair; import android.view.Gravity; import android.view.HapticFeedbackConstants; import android.view.LayoutInflater; @@ -56,6 +58,8 @@ import org.joinmastodon.android.E; import org.joinmastodon.android.GlobalUserPreferences; import org.joinmastodon.android.MastodonApp; import org.joinmastodon.android.R; +import org.joinmastodon.android.api.MastodonAPIRequest; +import org.joinmastodon.android.api.MastodonErrorResponse; import org.joinmastodon.android.api.StatusInteractionController; import org.joinmastodon.android.api.requests.accounts.GetAccountByHandle; import org.joinmastodon.android.api.requests.accounts.SetAccountBlocked; @@ -101,10 +105,9 @@ import org.parceler.Parcels; import java.io.File; import java.lang.reflect.Field; import java.lang.reflect.Method; +import java.net.IDN; import java.net.URI; import java.net.URISyntaxException; -import java.net.URL; -import java.time.Duration; import java.time.Instant; import java.time.ZoneId; import java.time.ZonedDateTime; @@ -147,15 +150,11 @@ public class UiUtils { private static Handler mainHandler = new Handler(Looper.getMainLooper()); private static final DateTimeFormatter DATE_FORMATTER_SHORT_WITH_YEAR = DateTimeFormatter.ofPattern("d MMM uuuu"), DATE_FORMATTER_SHORT = DateTimeFormatter.ofPattern("d MMM"); public static final DateTimeFormatter DATE_TIME_FORMATTER = DateTimeFormatter.ofLocalizedDateTime(FormatStyle.LONG, FormatStyle.SHORT); - public static int MAX_WIDTH; + public static int MAX_WIDTH, SCROLL_TO_TOP_DELTA; private UiUtils() { } - public static void loadMaxWidth(Context ctx) { - if (MAX_WIDTH == 0) MAX_WIDTH = (int) ctx.getResources().getDimension(R.dimen.layout_max_width); - } - public static void launchWebBrowser(Context context, String url) { try { if (GlobalUserPreferences.useCustomTabs) { @@ -197,19 +196,11 @@ public class UiUtils { } } - public static int alphaBlendColors(int color1, int color2, float alpha) { - float alpha0 = 1f - alpha; - int r = Math.round(((color1 >> 16) & 0xFF) * alpha0 + ((color2 >> 16) & 0xFF) * alpha); - int g = Math.round(((color1 >> 8) & 0xFF) * alpha0 + ((color2 >> 8) & 0xFF) * alpha); - int b = Math.round((color1 & 0xFF) * alpha0 + (color2 & 0xFF) * alpha); - return 0xFF000000 | (r << 16) | (g << 8) | b; - } - - public static String formatRelativeTimestampAsMinutesAgo(Context context, Instant instant){ - long t=instant.toEpochMilli(); - long now=System.currentTimeMillis(); - long diff=now-t; - if(diff<1000L){ + public static String formatRelativeTimestampAsMinutesAgo(Context context, Instant instant) { + long t = instant.toEpochMilli(); + long now = System.currentTimeMillis(); + long diff = now - t; + if (diff < 1000L) { return context.getString(R.string.time_just_now); } else if (diff < 60_000L) { int secs = (int) (diff / 1000L); @@ -285,7 +276,7 @@ public class UiUtils { mainHandler.post(runnable); } - public static void runOnUiThread(Runnable runnable, long delay){ + public static void runOnUiThread(Runnable runnable, long delay) { mainHandler.postDelayed(runnable, delay); } @@ -538,15 +529,15 @@ public class UiUtils { new SetAccountMuted(account.id, !currentlyMuted, muteDuration.get().getSeconds()) .setCallback(new Callback<>(){ @Override - public void onSuccess(Relationship result){ + public void onSuccess(Relationship result) { resultCallback.accept(result); - if(!currentlyMuted){ + if (!currentlyMuted) { E.post(new RemoveAccountPostsEvent(accountID, account.id, false)); } } @Override - public void onError(ErrorResponse error){ + public void onError(ErrorResponse error) { error.showToast(activity); } }) @@ -557,27 +548,28 @@ public class UiUtils { .setIcon(currentlyMuted ? R.drawable.ic_fluent_speaker_0_28_regular : R.drawable.ic_fluent_speaker_off_28_regular) .show(); } - public static void confirmDeletePost(Activity activity, String accountID, Status status, Consumer resultCallback){ + + public static void confirmDeletePost(Activity activity, String accountID, Status status, Consumer resultCallback) { confirmDeletePost(activity, accountID, status, resultCallback, false); } - public static void confirmDeletePost(Activity activity, String accountID, Status status, Consumer resultCallback, boolean forRedraft){ + public static void confirmDeletePost(Activity activity, String accountID, Status status, Consumer resultCallback, boolean forRedraft) { showConfirmationAlert(activity, forRedraft ? R.string.sk_confirm_delete_and_redraft_title : R.string.confirm_delete_title, forRedraft ? R.string.sk_confirm_delete_and_redraft : R.string.confirm_delete, forRedraft ? R.string.sk_delete_and_redraft : R.string.delete, forRedraft ? R.drawable.ic_fluent_arrow_clockwise_28_regular : R.drawable.ic_fluent_delete_28_regular, () -> new DeleteStatus(status.id) - .setCallback(new Callback<>(){ + .setCallback(new Callback<>() { @Override - public void onSuccess(Status result){ + public void onSuccess(Status result) { resultCallback.accept(result); AccountSessionManager.getInstance().getAccount(accountID).getCacheController().deleteStatus(status.id); E.post(new StatusDeletedEvent(status.id, accountID)); } @Override - public void onError(ErrorResponse error){ + public void onError(ErrorResponse error) { error.showToast(activity); } }) @@ -586,7 +578,7 @@ public class UiUtils { ); } - public static void confirmDeleteScheduledPost(Activity activity, String accountID, ScheduledStatus status, Runnable resultCallback){ + public static void confirmDeleteScheduledPost(Activity activity, String accountID, ScheduledStatus status, Runnable resultCallback) { boolean isDraft = status.scheduledAt.isAfter(CreateStatus.DRAFTS_AFTER_INSTANT); showConfirmationAlert(activity, isDraft ? R.string.sk_confirm_delete_draft_title : R.string.sk_confirm_delete_scheduled_post_title, @@ -602,7 +594,7 @@ public class UiUtils { } @Override - public void onError(ErrorResponse error){ + public void onError(ErrorResponse error) { error.showToast(activity); } }) @@ -611,13 +603,13 @@ public class UiUtils { ); } - public static void confirmPinPost(Activity activity, String accountID, Status status, boolean pinned, Consumer resultCallback){ + public static void confirmPinPost(Activity activity, String accountID, Status status, boolean pinned, Consumer resultCallback) { showConfirmationAlert(activity, pinned ? R.string.sk_confirm_pin_post_title : R.string.sk_confirm_unpin_post_title, pinned ? R.string.sk_confirm_pin_post : R.string.sk_confirm_unpin_post, pinned ? R.string.sk_pin_post : R.string.sk_unpin_post, pinned ? R.drawable.ic_fluent_pin_28_regular : R.drawable.ic_fluent_pin_off_28_regular, - ()->{ + () -> { new SetStatusPinned(status.id, pinned) .setCallback(new Callback<>() { @Override @@ -680,7 +672,7 @@ public class UiUtils { .exec(accountID)); } - public static void setRelationshipToActionButton(Relationship relationship, Button button){ + public static void setRelationshipToActionButton(Relationship relationship, Button button) { setRelationshipToActionButton(relationship, button, false); } @@ -784,17 +776,8 @@ public class UiUtils { if (relationship.blocking) { confirmToggleBlockUser(activity, accountID, account, true, resultCallback); - return; - } - - if(relationship.muting){ + } else if (relationship.muting) { confirmToggleMuteUser(activity, accountID, account, true, resultCallback); - return; - } - - progressCallback.accept(true); - if (!relationship.following && !relationship.requested) { - follow(activity, accountID, account, true, progressCallback, resultCallback); } else { showConfirmationAlert(activity, activity.getString(R.string.mo_confirm_unfollow_title), @@ -819,13 +802,14 @@ public class UiUtils { } } - @Override - public void onError(ErrorResponse error){ - error.showToast(activity); - progressCallback.accept(false); - } - }) - .exec(accountID); + @Override + public void onError(ErrorResponse error) { + error.showToast(activity); + progressCallback.accept(false); + } + }) + .exec(accountID); + } } @@ -932,8 +916,6 @@ public class UiUtils { } } - /// Add icons to the menu. - /// Passing in items will be colored to be visible on the background. public static void enableOptionsMenuIcons(Context context, Menu menu, @IdRes int... asAction) { if (menu.getClass().getSimpleName().equals("MenuBuilder")) { try { @@ -941,8 +923,8 @@ public class UiUtils { m.setAccessible(true); m.invoke(menu, true); enableMenuIcons(context, menu, asAction); + } catch (Exception ignored) { } - catch(Exception ignored){} } } @@ -982,6 +964,10 @@ public class UiUtils { ColorPalette palette = ColorPalette.palettes.get(GlobalUserPreferences.color); if (palette != null) palette.apply(context); + + Resources res = context.getResources(); + MAX_WIDTH = (int) res.getDimension(R.dimen.layout_max_width); + SCROLL_TO_TOP_DELTA = (int) res.getDimension(R.dimen.scroll_to_top_delta); } public static boolean isDarkTheme() { @@ -990,6 +976,32 @@ public class UiUtils { return theme == GlobalUserPreferences.ThemePreference.DARK; } + public static Optional>> parseFediverseHandle(String maybeFediHandle) { + // https://stackoverflow.com/a/26987741, except i put a + here ... v + String domainRegex = "^(((?!-))(xn--|_)?[a-z0-9-]{0,61}[a-z0-9]\\.)+(xn--)?([a-z0-9][a-z0-9\\-]{0,60}|[a-z0-9-]{1,30}\\.[a-z]{2,})$"; + if (maybeFediHandle.toLowerCase().startsWith("mailto:")) { + maybeFediHandle = maybeFediHandle.substring("mailto:".length()); + } + List parts = Arrays.stream(maybeFediHandle.split("@")) + .filter(part -> !part.isEmpty()) + .collect(Collectors.toList()); + if (parts.size() == 0 || !parts.get(0).matches("^[^/\\s]+$")) { + return Optional.empty(); + } else if (parts.size() == 2) { + try { + String domain = IDN.toASCII(parts.get(1)); + if (!domain.matches(domainRegex)) return Optional.empty(); + return Optional.of(Pair.create(parts.get(0), Optional.of(parts.get(1)))); + } catch (IllegalArgumentException ignored) { + return Optional.empty(); + } + } else if (maybeFediHandle.startsWith("@")) { + return Optional.of(Pair.create(parts.get(0), Optional.empty())); + } else { + return Optional.empty(); + } + } + // https://mastodon.foo.bar/@User // https://mastodon.foo.bar/@User/43456787654678 // https://pleroma.foo.bar/users/User @@ -1110,150 +1122,48 @@ public class UiUtils { }, null); } - public static void lookupStatus(Context context, Status queryStatus, String targetAccountID, @Nullable String sourceAccountID, Consumer resultConsumer) { - lookup(context, queryStatus, targetAccountID, sourceAccountID, GetSearchResults.Type.STATUSES, resultConsumer, results -> + public static Optional> lookupStatus(Context context, Status queryStatus, String targetAccountID, @Nullable String sourceAccountID, Consumer resultConsumer) { + return lookup(context, queryStatus, targetAccountID, sourceAccountID, GetSearchResults.Type.STATUSES, resultConsumer, results -> !results.statuses.isEmpty() ? Optional.of(results.statuses.get(0)) : Optional.empty() ); } - public static void lookupAccount(Context context, Account queryAccount, String targetAccountID, @Nullable String sourceAccountID, Consumer resultConsumer) { - lookup(context, queryAccount, targetAccountID, sourceAccountID, GetSearchResults.Type.ACCOUNTS, resultConsumer, results -> + public static Optional> lookupAccount(Context context, Account queryAccount, String targetAccountID, @Nullable String sourceAccountID, Consumer resultConsumer) { + return lookup(context, queryAccount, targetAccountID, sourceAccountID, GetSearchResults.Type.ACCOUNTS, resultConsumer, results -> !results.accounts.isEmpty() ? Optional.of(results.accounts.get(0)) : Optional.empty() ); } - public static void lookupRemoteStatus(Context context, Status queryStatus, String targetAccountID, @Nullable String sourceAccountID, Consumer resultConsumer) { - remoteLookup(context, queryStatus, targetAccountID, sourceAccountID, GetSearchResults.Type.STATUSES, resultConsumer, results -> - !results.statuses.isEmpty() ? Optional.of(results.statuses.get(0)) : Optional.empty() - ); - } - - public static void lookupRemoteAccount(Context context, Account queryAccount, String targetAccountID, @Nullable String sourceAccountID, Consumer resultConsumer) { - remoteLookup(context, queryAccount, targetAccountID, sourceAccountID, GetSearchResults.Type.ACCOUNTS, resultConsumer, results -> - !results.accounts.isEmpty() ? Optional.of(results.accounts.get(0)) : Optional.empty() - ); - } - - public static void lookup(Context context, T query, String targetAccountID, @Nullable String sourceAccountID, @Nullable GetSearchResults.Type type, Consumer resultConsumer, Function> extractResult) { + public static Optional> lookup(Context context, T query, String targetAccountID, @Nullable String sourceAccountID, @Nullable GetSearchResults.Type type, Consumer resultConsumer, Function> extractResult) { if (sourceAccountID != null && targetAccountID.startsWith(sourceAccountID.substring(0, sourceAccountID.indexOf('_')))) { resultConsumer.accept(query); - return; + return Optional.empty(); } - new GetSearchResults(query.getQuery(), type, true).setCallback(new Callback<>() { - @Override - public void onSuccess(SearchResults results) { - Optional result = extractResult.apply(results); - if (result.isPresent()) resultConsumer.accept(result.get()); - else { - Toast.makeText(context, R.string.sk_resource_not_found, Toast.LENGTH_SHORT).show(); - resultConsumer.accept(null); - } - } + return Optional.of(new GetSearchResults(query.getQuery(), type, true).setCallback(new Callback<>() { + @Override + public void onSuccess(SearchResults results) { + Optional result = extractResult.apply(results); + if (result.isPresent()) resultConsumer.accept(result.get()); + else { + Toast.makeText(context, R.string.sk_resource_not_found, Toast.LENGTH_SHORT).show(); + resultConsumer.accept(null); + } + } - @Override - public void onError(ErrorResponse error) { - error.showToast(context); - } - }) + @Override + public void onError(ErrorResponse error) { + error.showToast(context); + } + }) .wrapProgress((Activity) context, R.string.loading, true, d -> transformDialogForLookup(context, targetAccountID, null, d)) - .exec(targetAccountID); + .exec(targetAccountID)); } - public static void remoteLookup(Context context, T query, String targetAccountID, @Nullable String sourceAccountID, @Nullable GetSearchResults.Type type, Consumer resultConsumer, Function> extractResult) { - if (sourceAccountID != null && targetAccountID.startsWith(sourceAccountID.substring(0, sourceAccountID.indexOf('_')))) { - resultConsumer.accept(query); - return; - } - - Pattern pattern = Pattern.compile("(?<=\\/\\/)([^\\/]+)(?=\\/@)"); - Matcher matcher = pattern.matcher(query.getQuery()); - String domain = null; - if(matcher.find()){ - domain = matcher.group(1); - } - -// if(domain == null){ -// return; -// } - - Pattern patternForQuery = Pattern.compile("https?:\\/\\/[^\\/]+\\/@(\\w+)"); - Matcher matcherForQuery = patternForQuery.matcher(query.getQuery()); - String trimmedQuery = null; - - if(matcherForQuery.find()){ - trimmedQuery = matcherForQuery.group(1); - } - -// if(trimmedQuery == null){ -// return; -// } - - if(query instanceof Account){ - domain = ((Account) query).getDomain(); - trimmedQuery = ((Account) query).username; - } - - String finalDomain = domain; - - if(query instanceof Account){ - new GetAccountByHandle(((Account) query).acct) - .setCallback(new Callback() { - @Override - public void onSuccess(Account result) { - if(result != null){ - resultConsumer.accept((T) result); - } else { - Toast.makeText(context, R.string.sk_resource_not_found, Toast.LENGTH_SHORT).show(); - resultConsumer.accept(null); - } - } - - @Override - public void onError(ErrorResponse error) { - error.showToast(context); - resultConsumer.accept(null); - } - }) - .wrapProgress((Activity)context, R.string.loading, true, - d -> transformDialogForLookup(context, targetAccountID, null, d, finalDomain)) - .execNoAuth(domain); - return; - } - new GetSearchResults(trimmedQuery, type, false).setCallback(new Callback<>() { - @Override - public void onSuccess(SearchResults results) { - Optional result = extractResult.apply(results); - if (result.isPresent()) resultConsumer.accept(result.get()); - else { - Toast.makeText(context, R.string.sk_resource_not_found, Toast.LENGTH_SHORT).show(); - resultConsumer.accept(null); - } - } - - @Override - public void onError(ErrorResponse error) { - error.showToast(context); - resultConsumer.accept(null); - } - }) - .wrapProgress((Activity)context, R.string.loading, true, - d -> transformDialogForLookup(context, targetAccountID, null, d, finalDomain)) - .execNoAuth(domain); - } - - public static void openURL(Context context, String accountID, String url) { - openURL(context, accountID, url, true); - } - - private static void transformDialogForLookup(Context context, String accountID, @Nullable String url, ProgressDialog dialog){ - transformDialogForLookup(context, accountID, url, dialog, null); - } - - private static void transformDialogForLookup(Context context, String accountID, @Nullable String url, ProgressDialog dialog, @Nullable String instanceName) { + public static void transformDialogForLookup(Context context, String accountID, @Nullable String url, ProgressDialog dialog) { if (accountID != null) { - dialog.setTitle(context.getString(R.string.sk_loading_resource_on_instance_title, instanceName != null ? instanceName : getInstanceName(accountID))); + dialog.setTitle(context.getString(R.string.sk_loading_resource_on_instance_title, getInstanceName(accountID))); } else { dialog.setTitle(R.string.sk_loading_fediverse_resource_title); } @@ -1266,19 +1176,97 @@ public class UiUtils { } } - public static void openURL(Context context, String accountID, String url, boolean launchBrowser) { - lookupURL(context, accountID, url, launchBrowser, (clazz, args) -> { - if (clazz == null) return; - Nav.go((Activity) context, clazz, args); - }); + private static Bundle bundleError(String error) { + Bundle args = new Bundle(); + args.putString("error", error); + return args; } - public static void lookupURL(Context context, String accountID, String url, boolean launchBrowser, BiConsumer, Bundle> go) { + private static Bundle bundleError(ErrorResponse error) { + Bundle args = new Bundle(); + if (error instanceof MastodonErrorResponse e) { + args.putString("error", e.error); + args.putInt("httpStatus", e.httpStatus); + } + return args; + } + + public static void openURL(Context context, String accountID, String url) { + openURL(context, accountID, url, true); + } + + public static void openURL(Context context, String accountID, String url, boolean launchBrowser) { + lookupURL(context, accountID, url, (clazz, args) -> { + if (clazz == null) { + if (args != null && args.containsKey("error")) Toast.makeText(context, args.getString("error"), Toast.LENGTH_SHORT).show(); + if (launchBrowser) launchWebBrowser(context, url); + return; + } + Nav.go((Activity) context, clazz, args); + }).map(req -> req.wrapProgress((Activity) context, R.string.loading, true, d -> + transformDialogForLookup(context, accountID, url, d))); + } + + public static boolean acctMatches(String accountID, String acct, String queriedUsername, @Nullable String queriedDomain) { + // check if the username matches + if (!acct.split("@")[0].equalsIgnoreCase(queriedUsername)) return false; + + boolean resultOnHomeInstance = !acct.contains("@"); + if (resultOnHomeInstance) { + // acct is formatted like 'someone' + // only allow home instance result if query didn't specify a domain, + // or the specified domain does, in fact, match the account session's domain + AccountSession session = AccountSessionManager.getInstance().getAccount(accountID); + return queriedDomain == null || session.domain.equalsIgnoreCase(queriedDomain); + } else if (queriedDomain == null) { + // accept whatever result we have as there's no queried domain to compare to + return true; + } else { + // acct is formatted like 'someone@somewhere' + return acct.split("@")[1].equalsIgnoreCase(queriedDomain); + } + } + + public static Optional> lookupAccountHandle(Context context, String accountID, String query, BiConsumer, Bundle> go) { + return parseFediverseHandle(query).map( + handle -> lookupAccountHandle(context, accountID, handle, go)) + .or(() -> { + go.accept(null, null); + return Optional.empty(); + }); + } + public static MastodonAPIRequest lookupAccountHandle(Context context, String accountID, Pair> queryHandle, BiConsumer, Bundle> go) { + String fullHandle = ("@" + queryHandle.first) + (queryHandle.second.map(domain -> "@" + domain).orElse("")); + return new GetSearchResults(fullHandle, GetSearchResults.Type.ACCOUNTS, true) + .setCallback(new Callback<>() { + @Override + public void onSuccess(SearchResults results) { + Bundle args = new Bundle(); + args.putString("account", accountID); + Optional account = results.accounts.stream() + .filter(a -> acctMatches(accountID, a.acct, queryHandle.first, queryHandle.second.orElse(null))) + .findAny(); + if (account.isPresent()) { + args.putParcelable("profileAccount", Parcels.wrap(account.get())); + go.accept(ProfileFragment.class, args); + return; + } + go.accept(null, bundleError(context.getString(R.string.sk_resource_not_found))); + } + + @Override + public void onError(ErrorResponse error) { + go.accept(null, bundleError(error)); + } + }).exec(accountID); + } + + public static Optional> lookupURL(Context context, String accountID, String url, BiConsumer, Bundle> go) { Uri uri = Uri.parse(url); List path = uri.getPathSegments(); if (accountID != null && "https".equals(uri.getScheme())) { if (path.size() == 2 && path.get(0).matches("^@[a-zA-Z0-9_]+$") && path.get(1).matches("^[0-9]+$") && AccountSessionManager.getInstance().getAccount(accountID).domain.equalsIgnoreCase(uri.getAuthority())) { - new GetStatusByID(path.get(1)) + return Optional.of(new GetStatusByID(path.get(1)) .setCallback(new Callback<>() { @Override public void onSuccess(Status result) { @@ -1290,17 +1278,12 @@ public class UiUtils { @Override public void onError(ErrorResponse error) { - error.showToast(context); - if (launchBrowser) launchWebBrowser(context, url); - go.accept(null, null); + go.accept(null, bundleError(error)); } }) - .wrapProgress((Activity) context, R.string.loading, true, - d -> transformDialogForLookup(context, accountID, url, d)) - .exec(accountID); - return; - } else if (looksLikeMastodonUrl(url)) { - new GetSearchResults(url, null, true) + .exec(accountID)); + } else if (looksLikeFediverseUrl(url)) { + return Optional.of(new GetSearchResults(url, null, true) .setCallback(new Callback<>() { @Override public void onSuccess(SearchResults results) { @@ -1318,26 +1301,19 @@ public class UiUtils { go.accept(ProfileFragment.class, args); return; } - if (launchBrowser) launchWebBrowser(context, url); - Toast.makeText(context, R.string.sk_resource_not_found, Toast.LENGTH_SHORT).show(); - go.accept(null, null); + go.accept(null, bundleError(context.getString(R.string.sk_resource_not_found))); } @Override public void onError(ErrorResponse error) { - error.showToast(context); - if (launchBrowser) launchWebBrowser(context, url); - go.accept(null, null); + go.accept(null, bundleError(error)); } }) - .wrapProgress((Activity) context, R.string.loading, true, - d -> transformDialogForLookup(context, accountID, url, d)) - .exec(accountID); - return; + .exec(accountID)); } } - if (launchBrowser) launchWebBrowser(context, url); go.accept(null, null); + return Optional.empty(); } public static void copyText(View v, String text) { @@ -1367,6 +1343,14 @@ public class UiUtils { return !TextUtils.isEmpty(getSystemProperty("ro.build.version.emui")); } + public static int alphaBlendColors(int color1, int color2, float alpha) { + float alpha0 = 1f - alpha; + int r = Math.round(((color1 >> 16) & 0xFF) * alpha0 + ((color2 >> 16) & 0xFF) * alpha); + int g = Math.round(((color1 >> 8) & 0xFF) * alpha0 + ((color2 >> 8) & 0xFF) * alpha); + int b = Math.round((color1 & 0xFF) * alpha0 + (color2 & 0xFF) * alpha); + return 0xFF000000 | (r << 16) | (g << 8) | b; + } + public static boolean pickAccountForCompose(Activity activity, String accountID, String prefilledText) { Bundle args = new Bundle(); if (prefilledText != null) args.putString("prefilledText", prefilledText); @@ -1389,15 +1373,6 @@ public class UiUtils { } } -// public static String getVisibilityText(Status status) { -// return MastodonApp.context.getString(switch (status.visibility) { -// case PUBLIC -> R.string.visibility_public; -// case UNLISTED -> R.string.sk_visibility_unlisted; -// case PRIVATE -> R.string.visibility_followers_only; -// case DIRECT -> R.string.visibility_private;; -// }); -// } - // https://github.com/tuskyapp/Tusky/pull/3148 public static void reduceSwipeSensitivity(ViewPager2 pager) { try { @@ -1501,6 +1476,33 @@ public class UiUtils { }); } + public static void showFragmentForNotification(Context context, Notification n, String accountID, Bundle extras) { + if (extras == null) extras = new Bundle(); + extras.putString("account", accountID); + if (n.status!=null) { + Status status=n.status; + extras.putParcelable("status", Parcels.wrap(status.clone())); + Nav.go((Activity) context, ThreadFragment.class, extras); + } else if (n.report != null) { + String domain = AccountSessionManager.getInstance().getAccount(accountID).domain; + UiUtils.launchWebBrowser(context, "https://"+domain+"/admin/reports/"+n.report.id); + } else if (n.account != null) { + extras.putString("account", accountID); + extras.putParcelable("profileAccount", Parcels.wrap(n.account)); + Nav.go((Activity) context, ProfileFragment.class, extras); + } + } + + /** + * Scale the input value according to the device's scaled display density + * @param sp Input value in scale-independent pixels (sp) + * @return Scaled value in physical pixels (px) + */ + public static int sp(Context context, float sp){ + // TODO: replace with V.sp in next AppKit version + return Math.round(sp*context.getApplicationContext().getResources().getDisplayMetrics().scaledDensity); + } + /** * Wraps a View.OnClickListener to filter multiple clicks in succession. * Useful for buttons that perform some action that changes their state asynchronously. diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/views/ComposeMediaLayout.java b/mastodon/src/main/java/org/joinmastodon/android/ui/views/ComposeMediaLayout.java index f435cca48..7ee640324 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/ui/views/ComposeMediaLayout.java +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/views/ComposeMediaLayout.java @@ -23,7 +23,6 @@ public class ComposeMediaLayout extends ViewGroup{ public ComposeMediaLayout(Context context, AttributeSet attrs, int defStyle){ super(context, attrs, defStyle); - UiUtils.loadMaxWidth(context); } @Override diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/views/MaxWidthFrameLayout.java b/mastodon/src/main/java/org/joinmastodon/android/ui/views/MaxWidthFrameLayout.java index 364f030f1..5625037c1 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/ui/views/MaxWidthFrameLayout.java +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/views/MaxWidthFrameLayout.java @@ -3,12 +3,13 @@ package org.joinmastodon.android.ui.views; import android.content.Context; import android.content.res.TypedArray; import android.util.AttributeSet; +import android.view.ViewGroup; import android.widget.FrameLayout; import org.joinmastodon.android.R; public class MaxWidthFrameLayout extends FrameLayout{ - private int maxWidth; + private int maxWidth, defaultWidth; public MaxWidthFrameLayout(Context context){ this(context, null); @@ -22,6 +23,7 @@ public class MaxWidthFrameLayout extends FrameLayout{ super(context, attrs, defStyle); TypedArray ta=context.obtainStyledAttributes(attrs, R.styleable.MaxWidthFrameLayout); maxWidth=ta.getDimensionPixelSize(R.styleable.MaxWidthFrameLayout_android_maxWidth, Integer.MAX_VALUE); + defaultWidth=ta.getDimensionPixelSize(R.styleable.MaxWidthFrameLayout_defaultWidth, -1); ta.recycle(); } @@ -33,10 +35,19 @@ public class MaxWidthFrameLayout extends FrameLayout{ this.maxWidth=maxWidth; } + public int getDefaultWidth() { + return defaultWidth; + } + + public void setDefaultWidth(int defaultWidth) { + this.defaultWidth = defaultWidth; + } + @Override protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec){ if(MeasureSpec.getSize(widthMeasureSpec)>maxWidth){ - widthMeasureSpec=maxWidth | MeasureSpec.getMode(widthMeasureSpec); + int width = defaultWidth >= 0 ? defaultWidth : maxWidth; + widthMeasureSpec=width | MeasureSpec.getMode(widthMeasureSpec); } super.onMeasure(widthMeasureSpec, heightMeasureSpec); } diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/views/MediaGridLayout.java b/mastodon/src/main/java/org/joinmastodon/android/ui/views/MediaGridLayout.java index d4ca3e547..7d6c49624 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/ui/views/MediaGridLayout.java +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/views/MediaGridLayout.java @@ -27,7 +27,6 @@ public class MediaGridLayout extends ViewGroup{ public MediaGridLayout(Context context, AttributeSet attrs, int defStyle){ super(context, attrs, defStyle); - UiUtils.loadMaxWidth(context); } @Override diff --git a/mastodon/src/main/res/color/boost_icon.xml b/mastodon/src/main/res/color/boost_icon.xml index e50a9554a..9efc146c8 100644 --- a/mastodon/src/main/res/color/boost_icon.xml +++ b/mastodon/src/main/res/color/boost_icon.xml @@ -2,5 +2,5 @@ - + \ No newline at end of file diff --git a/mastodon/src/main/res/drawable/ic_announcements_24_badged.xml b/mastodon/src/main/res/drawable/ic_announcements_24_badged.xml index e5b51e160..1f767581d 100644 --- a/mastodon/src/main/res/drawable/ic_announcements_24_badged.xml +++ b/mastodon/src/main/res/drawable/ic_announcements_24_badged.xml @@ -3,7 +3,7 @@ - + diff --git a/mastodon/src/main/res/drawable/ic_fluent_arrow_repeat_all_20sp_filled.xml b/mastodon/src/main/res/drawable/ic_fluent_arrow_repeat_all_20sp_filled.xml new file mode 100644 index 000000000..dac0ca015 --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_fluent_arrow_repeat_all_20sp_filled.xml @@ -0,0 +1,7 @@ + + + + \ No newline at end of file diff --git a/mastodon/src/main/res/drawable/ic_fluent_arrow_reply_20sp_filled.xml b/mastodon/src/main/res/drawable/ic_fluent_arrow_reply_20sp_filled.xml new file mode 100644 index 000000000..0b67fae20 --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_fluent_arrow_reply_20sp_filled.xml @@ -0,0 +1,7 @@ + + + + \ No newline at end of file diff --git a/mastodon/src/main/res/drawable/ic_fluent_communication_24_regular.xml b/mastodon/src/main/res/drawable/ic_fluent_communication_24_regular.xml new file mode 100644 index 000000000..a9f610543 --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_fluent_communication_24_regular.xml @@ -0,0 +1,3 @@ + + + diff --git a/mastodon/src/main/res/drawable/ic_fluent_earth_20sp_regular.xml b/mastodon/src/main/res/drawable/ic_fluent_earth_20sp_regular.xml new file mode 100644 index 000000000..ba7d44180 --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_fluent_earth_20sp_regular.xml @@ -0,0 +1,7 @@ + + + + \ No newline at end of file diff --git a/mastodon/src/main/res/drawable/ic_fluent_lock_closed_20sp_filled.xml b/mastodon/src/main/res/drawable/ic_fluent_lock_closed_20sp_filled.xml new file mode 100644 index 000000000..e096e2ff0 --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_fluent_lock_closed_20sp_filled.xml @@ -0,0 +1,7 @@ + + + + \ No newline at end of file diff --git a/mastodon/src/main/res/drawable/ic_fluent_lock_open_20sp_regular.xml b/mastodon/src/main/res/drawable/ic_fluent_lock_open_20sp_regular.xml new file mode 100644 index 000000000..6d5df216f --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_fluent_lock_open_20sp_regular.xml @@ -0,0 +1,7 @@ + + + + \ No newline at end of file diff --git a/mastodon/src/main/res/drawable/ic_fluent_number_symbol_20sp_filled.xml b/mastodon/src/main/res/drawable/ic_fluent_number_symbol_20sp_filled.xml new file mode 100644 index 000000000..29a1c95e8 --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_fluent_number_symbol_20sp_filled.xml @@ -0,0 +1,7 @@ + + + + \ No newline at end of file diff --git a/mastodon/src/main/res/drawable/ic_fluent_star_off_24_regular.xml b/mastodon/src/main/res/drawable/ic_fluent_star_off_24_regular.xml new file mode 100644 index 000000000..9e46ebd1b --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_fluent_star_off_24_regular.xml @@ -0,0 +1,3 @@ + + + diff --git a/mastodon/src/main/res/drawable/ic_follow_requests_24_badged.xml b/mastodon/src/main/res/drawable/ic_follow_requests_24_badged.xml index 7f9048281..1eca85d57 100644 --- a/mastodon/src/main/res/drawable/ic_follow_requests_24_badged.xml +++ b/mastodon/src/main/res/drawable/ic_follow_requests_24_badged.xml @@ -3,7 +3,7 @@ - + diff --git a/mastodon/src/main/res/drawable/ic_settings_24_badged.xml b/mastodon/src/main/res/drawable/ic_settings_24_badged.xml index 98a6358e3..9a5ddcf48 100644 --- a/mastodon/src/main/res/drawable/ic_settings_24_badged.xml +++ b/mastodon/src/main/res/drawable/ic_settings_24_badged.xml @@ -3,7 +3,7 @@ - + diff --git a/mastodon/src/main/res/layout/display_item_reblog_or_reply_line.xml b/mastodon/src/main/res/layout/display_item_reblog_or_reply_line.xml index b15c5ae8d..c16e85f2e 100644 --- a/mastodon/src/main/res/layout/display_item_reblog_or_reply_line.xml +++ b/mastodon/src/main/res/layout/display_item_reblog_or_reply_line.xml @@ -14,7 +14,7 @@ android:paddingTop="16dp" android:paddingBottom="6dp" android:textAppearance="@style/m3_title_small" - android:drawableStart="@drawable/ic_fluent_arrow_reply_20_filled" + android:drawableStart="@drawable/ic_fluent_arrow_reply_20sp_filled" android:drawableTint="?android:textColorSecondary" android:drawablePadding="6dp" android:singleLine="true" diff --git a/mastodon/src/main/res/menu/settings_auto_reveal_spoiler.xml b/mastodon/src/main/res/menu/settings_auto_reveal_spoiler.xml new file mode 100644 index 000000000..f6a5e31ed --- /dev/null +++ b/mastodon/src/main/res/menu/settings_auto_reveal_spoiler.xml @@ -0,0 +1,12 @@ + + + + + + \ No newline at end of file diff --git a/mastodon/src/main/res/values-de-rDE/strings.xml b/mastodon/src/main/res/values-de-rDE/strings.xml index 1c1032192..78979a045 100644 --- a/mastodon/src/main/res/values-de-rDE/strings.xml +++ b/mastodon/src/main/res/values-de-rDE/strings.xml @@ -166,7 +166,7 @@ Während wir den Vorfall überprüfen, kannst du gegen %s weitere Maßnahmen ergreifen. %s entfolgen Entfolgen - Du wirst die eigenen und geteilten Beiträge des Kontos nicht mehr sehen können. Dass du das Profil stummgeschaltet hast, erfährt die Person nicht. + Du wirst deren (geteilte) Beiträge auf deiner Startseite nicht mehr sehen können. Sie werden nicht erfahren, dass sie stummgeschaltet sind. Dir wird es nicht länger möglich sein, die Beiträge dieses Konto zu sehen. Das blockierte Profil wird nicht mehr in der Lage sein, deine Beiträge zu sehen oder dir zu folgen. Die Person hinter dem Konto wird mitbekommen, dass du ihr Konto gesperrt hast. Möchtest du das nicht mehr sehen? Wenn du etwas auf Mastodon siehst, das dir nicht gefällt, kannst du die Person aus deinem Umfeld entfernen. @@ -175,7 +175,7 @@ Wähle einen Server basierend auf deinen Interessen oder deiner Region – oder einfach einen allgemeinen. Du kannst trotzdem mit jedem interagieren, egal auf welchem Server. Servername oder -adresse Server-Regeln - Mit dem Fortfahren erklärst du dich damit einverstanden, die folgenden Regeln zu befolgen, die von den %s-Moderatoren aufgestellt und umgesetzt werden. + Solltest du fortfahren, erklärst du dich mit den Serverregeln, die die Moderator*innen von %s aufgestellt haben und durchsetzen werden, einverstanden. Konto erstellen bearbeiten Name @@ -200,7 +200,7 @@ Überprüfe deinen Posteingang Klicke auf den Link, den wir dir geschickt haben, um %s zu bestätigen. Wir warten hier auf dich. - Kein Link erhalten? + Keinen Link erhalten? Erneut abschicken E-Mail-App öffnen Bestätigung per E-Mail zugeschickt diff --git a/mastodon/src/main/res/values-de-rDE/strings_sk.xml b/mastodon/src/main/res/values-de-rDE/strings_sk.xml index 8db7cf770..6563b2f26 100644 --- a/mastodon/src/main/res/values-de-rDE/strings_sk.xml +++ b/mastodon/src/main/res/values-de-rDE/strings_sk.xml @@ -274,4 +274,26 @@ Vor dem Teilen bestätigen hat reagiert hat mit %s reagiert + Mit Konto teilen + Mit Konto teilen oder öffnen + Bubble + Vorausgewählter Inhaltstyp für neue Beiträge – überschreibt den Wert, der unter „Einstellungen für Beiträge“ gesetzt ist. + Konnte nicht in der App öffnen + Nicht angegeben + Nur Text + HTML + Markdown + BBCode + MFM + Inhaltstyp + Das sind die neuesten Beiträge aus dem Netzwerk, das deine Instanz-Admins kuratiert haben. + Formatierung aktivieren + Standard-Inhaltstyp + Informationen zur Instanz momentan nicht verfügbar + In App öffnen + Dadurch lässt beim Erstellen von Beiträgen ein Inhaltstyp wie Markdown angeben. Nicht alle Instanzen unterstützen das. + Infos von Remote-Instanzen laden + keine Remote-Infos abrufbar + Konnte das Profil via %s nicht laden + Für vollständigere Auflistung von Follower*innen, Likes und Boosts können die Informationen von der Ursprungs-Instanz geladen werden. \ No newline at end of file diff --git a/mastodon/src/main/res/values-es-rES/strings_sk.xml b/mastodon/src/main/res/values-es-rES/strings_sk.xml index c8dc06230..c0a685e9a 100644 --- a/mastodon/src/main/res/values-es-rES/strings_sk.xml +++ b/mastodon/src/main/res/values-es-rES/strings_sk.xml @@ -286,4 +286,10 @@ Contenido por defecto Permite establecer un tipo de contenido como Markdown al crear una entrada. Ten en cuenta que no todas las instancias lo admiten. Permite preseleccionar un tipo de contenido al crear nuevas entradas, anulando el valor establecido en \"Preferencias de publicación\". + Estas son las publicaciones más recientes de la gente en tu servidor de Akkoma. + Burbuja + Información de la instancia temporalmente no disponible + Compartir o abrir con una cuenta + Abrir en la app + Compartir con una cuenta \ No newline at end of file diff --git a/mastodon/src/main/res/values-fr-rFR/strings_sk.xml b/mastodon/src/main/res/values-fr-rFR/strings_sk.xml index 01e013d7a..5f8a8baad 100644 --- a/mastodon/src/main/res/values-fr-rFR/strings_sk.xml +++ b/mastodon/src/main/res/values-fr-rFR/strings_sk.xml @@ -290,7 +290,8 @@ Ouvrir dans l\'application Partager avec le compte Partager ou ouvrir avec le compte - Ce sont les publications les plus récentes des personnes présentes dans la bulle de votre serveur Akkoma. + Ce sont les messages les plus récents du réseau organisés par vos administrateurs d\'instance. Bulle Informations sur l\'instance temporairement indisponibles + Impossible de l\'ouvrir dans l\'application \ No newline at end of file diff --git a/mastodon/src/main/res/values-pl-rPL/strings_sk.xml b/mastodon/src/main/res/values-pl-rPL/strings_sk.xml index d4822e6b0..42e9fbeab 100644 --- a/mastodon/src/main/res/values-pl-rPL/strings_sk.xml +++ b/mastodon/src/main/res/values-pl-rPL/strings_sk.xml @@ -274,4 +274,15 @@ Potwierdź przed podbiciem zareagował(a) z %s zareagował(a) + Domyślny rodzaj treści + Informacje o instancji są tymczasowo niedostępne + HTML + BBCode + MFM + Rodzaj treści + Nie określono + Czysty tekst + Włącz formatowanie wpisu + Otwórz w aplikacji + Udostępnij z kontem \ No newline at end of file diff --git a/mastodon/src/main/res/values-sv-rSE/strings.xml b/mastodon/src/main/res/values-sv-rSE/strings.xml index c69fe4787..0cef3870d 100644 --- a/mastodon/src/main/res/values-sv-rSE/strings.xml +++ b/mastodon/src/main/res/values-sv-rSE/strings.xml @@ -10,11 +10,13 @@ OK Förbereder för autentisering… Slutför autentisering… + %s boostade Som svar på %s Notiser följde dig skickade en förfrågning om att följa till dig favoritmarkerade dit inlägg + boostade ditt inlägg omröstning avslutad %ds %dm @@ -164,6 +166,7 @@ Medan vi granskar detta kan du vidta åtgärder mot %s. Avfölj %s Avfölj + Du kommer inte att se deras inlägg eller boosts i ditt hemflöde. De kommer inte veta att de har blivit tystade. De kommer inte längre att kunna följa eller se dina inlägg, men de kan se om de har blockerats. Vill du inte se det här? När du ser något som du inte gillar på Mastodon kan du ta bort personen från din upplevelse. @@ -412,7 +415,18 @@ %1$s tillåter inte registrering från %2$s. Prova en annan eller <a>välj en annan server</a>. Det här användarnamnet är redan taget. + Visa ändå + Välj en eller flera Spara ändringar + Tidslinje + Visa alla + Konton + Verifierad länk + Visa + Dölj + Gå med %s eller + Läs mer Välkommen till Mastodon + Vad är servrar? diff --git a/mastodon/src/main/res/values-tr-rTR/strings.xml b/mastodon/src/main/res/values-tr-rTR/strings.xml index 200af3542..27204032f 100644 --- a/mastodon/src/main/res/values-tr-rTR/strings.xml +++ b/mastodon/src/main/res/values-tr-rTR/strings.xml @@ -6,17 +6,17 @@ Sonraki Sunucu bilgisi alınıyor… Hata - %s bir Mastodon sunucusu gibi görünmüyor. + %s bir Mastodon sunucusu gibi görükmüyor. Tamam Kimlik doğrulama için hazırlanıyor… Kimlik doğrulama tamamlanıyor… - %s yineledi + %s paylaştı %s için yanıt Bildirimler sizi takip etti sana bir takip isteği gönderdi gönderinizi favorilerine ekledi - gönderinizi yineledi + gönderinizi paylaştı oylama sona erdi %ds %ddk @@ -123,7 +123,7 @@ Sil Gönderiyi sil Bu gönderiyi silmek istediğinizden emin misiniz? - Siliniyor... + Siliniyor Ses çal Oynat Durdur @@ -149,7 +149,7 @@ En iyi eşleşmeyi seçin Hoşuma gitmiyor Görmek isteyeceğin bir şey değil - Bu spam + Spam Kötü amaçlı bağlantılar, sahte etkileşim veya tekrarlayan yanıtlar Sunucu kurallarını ihlal ediyor Belirli kuralları çiğnediğinin farkındasınız @@ -166,7 +166,7 @@ Biz bunu incelerken siz %s karşı önlem alabilirsiniz. Takipten çık %s Takipten çık - Ana sayfa akışınızda kişinin gönderilerini görmeyeceksiniz. Sessize alındıklarını bilemeyecekler. + Anasayfa akışınızda kişinin gönderilerini görmeyeceksiniz. Sessize alındıklarını bilemeyecekler. Artık sizi takip edemez ve gönderilerinizi göremezler ama engellendiklerini görebilirler. Bunu görmek istemiyor musun? Mastodon\'da beğenmediğiniz bir şey gördüğünüzde, o kişiyi deneyiminizden çıkarabilirsiniz. @@ -200,10 +200,10 @@ E-posta Kutunuzu Kontrol Edin %s işleminizi doğrulamak için size gönderdiğimiz linke tıklayınız. Biz burada bekleyeceğiz. - Link size ulaşmadı mı? + Bağlantı size ulaşmadı mı? Yeniden gönder - E-posta uygulamasını aç - Onay e-postası gönderildi + Eposta uygulamasını aç + Onay epostası gönderildi Aklınızdan geçenleri yazın veya yapıştırın İçerik Uyarısı Resim açıklaması ekle… @@ -243,18 +243,18 @@ Animasyonlu avatarları ve emojileri oynat Uygulama içi tarayıcıyı kullan Bildirimler - Beni şu durumda bilgilendir: - Herhangi biri + Beni şu durumda bilgilendir + Herhangibiri Bir takipçim Takip ettiğim biri Bilgilendirme Gönderimi favorilerine eklediğinde Beni takip ettiğinde - Gönderimi yinelediğinde + Gönderimi paylaştığında Benden bahsettiğinde Sıkıcı bölge Hesap ayarları - Mastodon\'a katkıda bulunun + Mastodona katkıda bulunun Kullanım Şartları Gizlilik Politikası Tehlikeli bölge @@ -271,7 +271,7 @@ İçeriği gizle Yeni gönderi Cevapla - Yinele + Yeniden Paylaş Favorile Paylaş Açıklamasız medya @@ -282,7 +282,7 @@ Anasayfa Profilim Medya görüntüleyici - %s\'yi takip et + %s \'yi takip et %s takip edilmedi %s kişisini takip ediyorsunuz %s takip isteği gönderdi @@ -310,11 +310,11 @@ Bu gönderiler seninle aynı Mastodon sunucusunda olan kişilerin paylaştığı son gönderilerdir. Yoksay Yeni gönderileri gör - Daha fazla gönderi yükle - Geri Takip Et + Daha fazlası + Takip edeni Takip Et Bekliyor Seni takip ediyor - Takipçileri manuel kabul eder + Takipçileri elle kabul et Kullanılan hesap %s oturumunu kapat @@ -331,8 +331,8 @@ %,d favori - %,d yineleme - %,d yineleme + %,d paylaşma + %,d paylaşma %1$s tarihinde %2$s uygulamasıyla şimdi @@ -361,7 +361,7 @@ Medya eklendi Medya kaldırıldı Medya düzenlendi - Hassas olarak işarlendi + Hassas olarak işaretlendi Hassas değil olarak işaretlendi Gönderi düzenlendi Düzenle @@ -373,16 +373,16 @@ %.2f GB %2$s dosyadan %1$s %s kaldı - Cihazınızın internet bağlantısı koptu + Cihazınızın ağ bağlantısı koptu İşleniyor… - Mastodon Android uygulamasının %s versiyonu indirmeye hazır. + Mastodon, Android uygulamasının %s versiyonu indirmeye hazır. - Mastodon Android uygulamasının %s versiyonu indirildi ve kurulmaya hazır. + Mastodon , Android uygulamasının %s versiyonu indirildi ve kurulmaya hazır. İndir (%s) Kur - Gizliliğiniz + Gizliliğiniz. Mastodon uygulaması herhangi bir veri toplama dahi katıldığınız sunucunun farklı bir politikası olabilir. \n\n %s politikası sizi tatmin etmiyorsa geri giderek farklı bir sunucu seçebilirsiniz. Kabul ediyorum Bu liste boş @@ -392,9 +392,9 @@ Yer İmi Kaldır Yer İmleri Favorilerin - Hoşgeldin + Hoşgeldiniz Hesabınızı oluşturduğunuz sunucu ile giriş yapın. - Sunucu URL\'si + Sunucu bağlantısı Herhangi bir seçim yapmadan devam ederseniz dilinize göre bir sunucu seçeceğiz. Herhangi Bir Dil Koşulsuz Kayıt @@ -419,7 +419,7 @@ Reddet Kısacası: Hiçbir veri işlemiyor ya da toplamıyoruz. - %s\'yi reddet + %s \'yi reddet Hakkımda Hesaplar takip ediliyor… @@ -437,12 +437,12 @@ Onaylanmış bağlantı Göster Gizle - %s\'e katıl + %s katıl Başka sunucu seç veya - Daha fazla bilgi edinin + Daha fazlası Mastodon\'a hoş geldiniz - Mastodon merkezi olmayan bir sosyal ağdır, yani tek bir şirket tarafından kontrol edilmemektedir. Hepsi birbirine bağlı, bağımsız olarak işletilen birçok sunucudan oluşur. + Mastodon , Merkezi olmayan bir sosyal ağdır, yani tek bir şirket tarafından kontrol edilmemektedir. Hepsi birbirine bağlı, bağımsız olarak işletilen birçok sunucudan oluşur. Sunucular nelerdir? - + diff --git a/mastodon/src/main/res/values-uk-rUA/strings_sk.xml b/mastodon/src/main/res/values-uk-rUA/strings_sk.xml index 58eb54971..95cd0823c 100644 --- a/mastodon/src/main/res/values-uk-rUA/strings_sk.xml +++ b/mastodon/src/main/res/values-uk-rUA/strings_sk.xml @@ -288,8 +288,9 @@ Дозволяє налаштувати тип вмісту, наприклад, Markdown, під час написання допису. Зауважте, що не всі сервери підтримують цю функцію. Відкрити у застосунку Поділитися через обліковий запис - Це найновіші дописи людей у бульбашці вашого сервера Akkoma. + Це найновіші дописи з мережі керованої адміністраторами вашого сервера. Бульбашка Сервер тимчасово недоступний Поділитися або відкрити за допомогою облікового запису + Не вдалося відкрити в застосунку \ No newline at end of file diff --git a/mastodon/src/main/res/values/attrs.xml b/mastodon/src/main/res/values/attrs.xml index 214ba3b15..910ca5cd9 100644 --- a/mastodon/src/main/res/values/attrs.xml +++ b/mastodon/src/main/res/values/attrs.xml @@ -23,6 +23,7 @@ + @@ -75,6 +76,7 @@ + diff --git a/mastodon/src/main/res/values/dimens.xml b/mastodon/src/main/res/values/dimens.xml index 1236e158b..024d53a00 100644 --- a/mastodon/src/main/res/values/dimens.xml +++ b/mastodon/src/main/res/values/dimens.xml @@ -5,4 +5,5 @@ 450dp 1000dp 445dp + 300dp \ No newline at end of file diff --git a/mastodon/src/main/res/values/strings_sk.xml b/mastodon/src/main/res/values/strings_sk.xml index 7dd3b6737..3e2b78556 100644 --- a/mastodon/src/main/res/values/strings_sk.xml +++ b/mastodon/src/main/res/values/strings_sk.xml @@ -30,7 +30,7 @@ Turned off post notifications for %s Federation These are the most recent posts by the people in your federation. - These are the most recent posts by the people in your Akkoma server\'s bubble. + These are the most recent posts from the network curated by your instance admins. Megalodon %s is ready to download. Megalodon %s is downloaded and ready to install. Check for update @@ -290,6 +290,16 @@ This lets you have a content type be pre-selected when creating new posts, overriding the value set in “Posting preferences”. Instance info temporarily unavailable Open in app + Could not open in app Share with account Share or open with account + remote info unavailable + Failed loading the profile via %s + Load info from remote instances + Try fetching more accurate listings for followers, likes and boosts by loading the information from the instance of origin. + Reveal equal CWs in threads + Never + Same author + Discussions + Always \ No newline at end of file diff --git a/mastodon/src/main/res/values/styles.xml b/mastodon/src/main/res/values/styles.xml index a848e37b4..c3911c41b 100644 --- a/mastodon/src/main/res/values/styles.xml +++ b/mastodon/src/main/res/values/styles.xml @@ -28,6 +28,7 @@ ?colorGray100 ?colorGray800 ?colorGray500 + ?colorGray300 ?colorGray50 ?colorGray200 ?colorGray50 @@ -127,6 +128,7 @@ ?colorGray700 ?colorGray50 ?colorGray400 + ?colorGray500 ?colorGray800 ?colorGray200 ?colorGray700