diff --git a/mastodon/.editorconfig b/mastodon/.editorconfig new file mode 100644 index 000000000..ae8587e0c --- /dev/null +++ b/mastodon/.editorconfig @@ -0,0 +1,279 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +indent_size = 4 +indent_style = tab +insert_final_newline = false +max_line_length = 300 +tab_width = 4 +ij_continuation_indent_size = 8 +ij_formatter_off_tag = @formatter:off +ij_formatter_on_tag = @formatter:on +ij_formatter_tags_enabled = false +ij_smart_tabs = false +ij_visual_guides = none +ij_wrap_on_typing = false + +[*.java] +ij_java_align_consecutive_assignments = false +ij_java_align_consecutive_variable_declarations = false +ij_java_align_group_field_declarations = false +ij_java_align_multiline_annotation_parameters = false +ij_java_align_multiline_array_initializer_expression = false +ij_java_align_multiline_assignment = false +ij_java_align_multiline_binary_operation = false +ij_java_align_multiline_chained_methods = false +ij_java_align_multiline_extends_list = false +ij_java_align_multiline_for = true +ij_java_align_multiline_method_parentheses = false +ij_java_align_multiline_parameters = true +ij_java_align_multiline_parameters_in_calls = false +ij_java_align_multiline_parenthesized_expression = false +ij_java_align_multiline_records = true +ij_java_align_multiline_resources = true +ij_java_align_multiline_ternary_operation = false +ij_java_align_multiline_text_blocks = false +ij_java_align_multiline_throws_list = false +ij_java_align_subsequent_simple_methods = false +ij_java_align_throws_keyword = false +ij_java_align_types_in_multi_catch = true +ij_java_annotation_parameter_wrap = off +ij_java_array_initializer_new_line_after_left_brace = false +ij_java_array_initializer_right_brace_on_new_line = false +ij_java_array_initializer_wrap = off +ij_java_assert_statement_colon_on_next_line = false +ij_java_assert_statement_wrap = off +ij_java_assignment_wrap = off +ij_java_binary_operation_sign_on_next_line = false +ij_java_binary_operation_wrap = off +ij_java_blank_lines_after_anonymous_class_header = 0 +ij_java_blank_lines_after_class_header = 0 +ij_java_blank_lines_after_imports = 1 +ij_java_blank_lines_after_package = 1 +ij_java_blank_lines_around_class = 1 +ij_java_blank_lines_around_field = 0 +ij_java_blank_lines_around_field_in_interface = 0 +ij_java_blank_lines_around_initializer = 1 +ij_java_blank_lines_around_method = 1 +ij_java_blank_lines_around_method_in_interface = 1 +ij_java_blank_lines_before_class_end = 0 +ij_java_blank_lines_before_imports = 1 +ij_java_blank_lines_before_method_body = 0 +ij_java_blank_lines_before_package = 0 +ij_java_block_brace_style = end_of_line +ij_java_block_comment_add_space = false +ij_java_block_comment_at_first_column = true +ij_java_builder_methods = none +ij_java_call_parameters_new_line_after_left_paren = false +ij_java_call_parameters_right_paren_on_new_line = false +ij_java_call_parameters_wrap = off +ij_java_case_statement_on_separate_line = true +ij_java_catch_on_new_line = false +ij_java_class_annotation_wrap = split_into_lines +ij_java_class_brace_style = end_of_line +ij_java_class_count_to_use_import_on_demand = 99 +ij_java_class_names_in_javadoc = 1 +ij_java_do_not_indent_top_level_class_members = false +ij_java_do_not_wrap_after_single_annotation = false +ij_java_do_not_wrap_after_single_annotation_in_parameter = false +ij_java_do_while_brace_force = never +ij_java_doc_add_blank_line_after_description = true +ij_java_doc_add_blank_line_after_param_comments = false +ij_java_doc_add_blank_line_after_return = false +ij_java_doc_add_p_tag_on_empty_lines = true +ij_java_doc_align_exception_comments = true +ij_java_doc_align_param_comments = true +ij_java_doc_do_not_wrap_if_one_line = false +ij_java_doc_enable_formatting = true +ij_java_doc_enable_leading_asterisks = true +ij_java_doc_indent_on_continuation = false +ij_java_doc_keep_empty_lines = true +ij_java_doc_keep_empty_parameter_tag = true +ij_java_doc_keep_empty_return_tag = true +ij_java_doc_keep_empty_throws_tag = true +ij_java_doc_keep_invalid_tags = true +ij_java_doc_param_description_on_new_line = false +ij_java_doc_preserve_line_breaks = false +ij_java_doc_use_throws_not_exception_tag = true +ij_java_else_on_new_line = false +ij_java_enum_constants_wrap = off +ij_java_extends_keyword_wrap = off +ij_java_extends_list_wrap = off +ij_java_field_annotation_wrap = split_into_lines +ij_java_finally_on_new_line = false +ij_java_for_brace_force = never +ij_java_for_statement_new_line_after_left_paren = false +ij_java_for_statement_right_paren_on_new_line = false +ij_java_for_statement_wrap = off +ij_java_generate_final_locals = false +ij_java_generate_final_parameters = false +ij_java_if_brace_force = never +ij_java_imports_layout = android.**,|,com.**,|,junit.**,|,net.**,|,org.**,|,java.**,|,javax.**,|,*,|,$*,| +ij_java_indent_case_from_switch = true +ij_java_insert_inner_class_imports = false +ij_java_insert_override_annotation = true +ij_java_keep_blank_lines_before_right_brace = 2 +ij_java_keep_blank_lines_between_package_declaration_and_header = 2 +ij_java_keep_blank_lines_in_code = 2 +ij_java_keep_blank_lines_in_declarations = 2 +ij_java_keep_builder_methods_indents = false +ij_java_keep_control_statement_in_one_line = true +ij_java_keep_first_column_comment = true +ij_java_keep_indents_on_empty_lines = false +ij_java_keep_line_breaks = true +ij_java_keep_multiple_expressions_in_one_line = false +ij_java_keep_simple_blocks_in_one_line = false +ij_java_keep_simple_classes_in_one_line = false +ij_java_keep_simple_lambdas_in_one_line = false +ij_java_keep_simple_methods_in_one_line = false +ij_java_label_indent_absolute = false +ij_java_label_indent_size = 0 +ij_java_lambda_brace_style = end_of_line +ij_java_layout_static_imports_separately = true +ij_java_line_comment_add_space = false +ij_java_line_comment_add_space_on_reformat = false +ij_java_line_comment_at_first_column = true +ij_java_method_annotation_wrap = split_into_lines +ij_java_method_brace_style = end_of_line +ij_java_method_call_chain_wrap = off +ij_java_method_parameters_new_line_after_left_paren = false +ij_java_method_parameters_right_paren_on_new_line = false +ij_java_method_parameters_wrap = off +ij_java_modifier_list_wrap = false +ij_java_multi_catch_types_wrap = normal +ij_java_names_count_to_use_import_on_demand = 99 +ij_java_new_line_after_lparen_in_annotation = false +ij_java_new_line_after_lparen_in_record_header = false +ij_java_parameter_annotation_wrap = off +ij_java_parentheses_expression_new_line_after_left_paren = false +ij_java_parentheses_expression_right_paren_on_new_line = false +ij_java_place_assignment_sign_on_next_line = false +ij_java_prefer_longer_names = true +ij_java_prefer_parameters_wrap = false +ij_java_record_components_wrap = normal +ij_java_repeat_synchronized = true +ij_java_replace_instanceof_and_cast = false +ij_java_replace_null_check = true +ij_java_replace_sum_lambda_with_method_ref = true +ij_java_resource_list_new_line_after_left_paren = false +ij_java_resource_list_right_paren_on_new_line = false +ij_java_resource_list_wrap = off +ij_java_rparen_on_new_line_in_annotation = false +ij_java_rparen_on_new_line_in_record_header = false +ij_java_space_after_closing_angle_bracket_in_type_argument = false +ij_java_space_after_colon = true +ij_java_space_after_comma = true +ij_java_space_after_comma_in_type_arguments = true +ij_java_space_after_for_semicolon = true +ij_java_space_after_quest = true +ij_java_space_after_type_cast = true +ij_java_space_before_annotation_array_initializer_left_brace = false +ij_java_space_before_annotation_parameter_list = false +ij_java_space_before_array_initializer_left_brace = false +ij_java_space_before_catch_keyword = false +ij_java_space_before_catch_left_brace = false +ij_java_space_before_catch_parentheses = false +ij_java_space_before_class_left_brace = false +ij_java_space_before_colon = true +ij_java_space_before_colon_in_foreach = true +ij_java_space_before_comma = false +ij_java_space_before_do_left_brace = false +ij_java_space_before_else_keyword = false +ij_java_space_before_else_left_brace = false +ij_java_space_before_finally_keyword = false +ij_java_space_before_finally_left_brace = false +ij_java_space_before_for_left_brace = false +ij_java_space_before_for_parentheses = false +ij_java_space_before_for_semicolon = false +ij_java_space_before_if_left_brace = false +ij_java_space_before_if_parentheses = false +ij_java_space_before_method_call_parentheses = false +ij_java_space_before_method_left_brace = false +ij_java_space_before_method_parentheses = false +ij_java_space_before_opening_angle_bracket_in_type_parameter = false +ij_java_space_before_quest = true +ij_java_space_before_switch_left_brace = false +ij_java_space_before_switch_parentheses = false +ij_java_space_before_synchronized_left_brace = false +ij_java_space_before_synchronized_parentheses = false +ij_java_space_before_try_left_brace = false +ij_java_space_before_try_parentheses = false +ij_java_space_before_type_parameter_list = false +ij_java_space_before_while_keyword = false +ij_java_space_before_while_left_brace = false +ij_java_space_before_while_parentheses = false +ij_java_space_inside_one_line_enum_braces = false +ij_java_space_within_empty_array_initializer_braces = false +ij_java_space_within_empty_method_call_parentheses = false +ij_java_space_within_empty_method_parentheses = false +ij_java_spaces_around_additive_operators = false +ij_java_spaces_around_annotation_eq = true +ij_java_spaces_around_assignment_operators = false +ij_java_spaces_around_bitwise_operators = false +ij_java_spaces_around_equality_operators = false +ij_java_spaces_around_lambda_arrow = false +ij_java_spaces_around_logical_operators = true +ij_java_spaces_around_method_ref_dbl_colon = false +ij_java_spaces_around_multiplicative_operators = false +ij_java_spaces_around_relational_operators = false +ij_java_spaces_around_shift_operators = false +ij_java_spaces_around_type_bounds_in_type_parameters = true +ij_java_spaces_around_unary_operator = false +ij_java_spaces_within_angle_brackets = false +ij_java_spaces_within_annotation_parentheses = false +ij_java_spaces_within_array_initializer_braces = false +ij_java_spaces_within_braces = false +ij_java_spaces_within_brackets = false +ij_java_spaces_within_cast_parentheses = false +ij_java_spaces_within_catch_parentheses = false +ij_java_spaces_within_for_parentheses = false +ij_java_spaces_within_if_parentheses = false +ij_java_spaces_within_method_call_parentheses = false +ij_java_spaces_within_method_parentheses = false +ij_java_spaces_within_parentheses = false +ij_java_spaces_within_record_header = false +ij_java_spaces_within_switch_parentheses = false +ij_java_spaces_within_synchronized_parentheses = false +ij_java_spaces_within_try_parentheses = false +ij_java_spaces_within_while_parentheses = false +ij_java_special_else_if_treatment = true +ij_java_subclass_name_suffix = Impl +ij_java_ternary_operation_signs_on_next_line = false +ij_java_ternary_operation_wrap = off +ij_java_test_name_suffix = Test +ij_java_throws_keyword_wrap = off +ij_java_throws_list_wrap = off +ij_java_use_external_annotations = false +ij_java_use_fq_class_names = false +ij_java_use_relative_indents = false +ij_java_use_single_class_imports = true +ij_java_variable_annotation_wrap = off +ij_java_visibility = public +ij_java_while_brace_force = never +ij_java_while_on_new_line = false +ij_java_wrap_comments = false +ij_java_wrap_first_method_in_call_chain = false +ij_java_wrap_long_lines = false + +[{*.ant,*.fxml,*.jhm,*.jnlp,*.jrxml,*.rng,*.tld,*.wsdl,*.xml,*.xsd,*.xsl,*.xslt,*.xul}] +ij_continuation_indent_size = 4 +ij_xml_align_attributes = false +ij_xml_align_text = false +ij_xml_attribute_wrap = normal +ij_xml_block_comment_add_space = false +ij_xml_block_comment_at_first_column = true +ij_xml_keep_blank_lines = 2 +ij_xml_keep_indents_on_empty_lines = false +ij_xml_keep_line_breaks = false +ij_xml_keep_line_breaks_in_text = true +ij_xml_keep_whitespaces = false +ij_xml_keep_whitespaces_around_cdata = preserve +ij_xml_keep_whitespaces_inside_cdata = false +ij_xml_line_comment_at_first_column = true +ij_xml_space_after_tag_name = false +ij_xml_space_around_equals_in_attribute = false +ij_xml_space_inside_empty_tag = true +ij_xml_text_wrap = normal +ij_xml_use_custom_settings = true diff --git a/mastodon/build.gradle b/mastodon/build.gradle index 788d34c97..36d386716 100644 --- a/mastodon/build.gradle +++ b/mastodon/build.gradle @@ -15,8 +15,8 @@ android { applicationId "org.joinmastodon.android.sk" minSdk 23 targetSdk 33 - versionCode 95 - versionName "1.2.3+fork.95" + versionCode 96 + versionName "2.0.1+fork.96" 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'] } @@ -68,7 +68,8 @@ 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.8' + implementation 'me.grishka.litex:palette:1.0.0' + implementation 'me.grishka.appkit:appkit:1.2.9' implementation 'com.google.code.gson:gson:2.9.0' implementation 'org.jsoup:jsoup:1.14.3' implementation 'com.squareup:otto:1.3.8' @@ -78,8 +79,8 @@ dependencies { annotationProcessor 'org.parceler:parceler:1.1.12' coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.0.3' - androidTestImplementation 'androidx.test:core:1.4.1-alpha05' - androidTestImplementation 'androidx.test.ext:junit:1.1.4-alpha05' - androidTestImplementation 'androidx.test:runner:1.5.0-alpha02' - androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.0-alpha05' + androidTestImplementation 'androidx.test:core:1.5.0' + androidTestImplementation 'androidx.test.ext:junit:1.1.5' + androidTestImplementation 'androidx.test:runner:1.5.2' + androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1' } diff --git a/mastodon/src/androidTest/java/org/joinmastodon/android/test/StoreScreenshotsGenerator.java b/mastodon/src/androidTest/java/org/joinmastodon/android/test/StoreScreenshotsGenerator.java index d895e4cca..301771b7e 100644 --- a/mastodon/src/androidTest/java/org/joinmastodon/android/test/StoreScreenshotsGenerator.java +++ b/mastodon/src/androidTest/java/org/joinmastodon/android/test/StoreScreenshotsGenerator.java @@ -65,6 +65,7 @@ import static androidx.test.espresso.matcher.ViewMatchers.*; @LargeTest public class StoreScreenshotsGenerator{ private static final String PHOTO_FILE="IMG_1010.jpg"; + private static final long LOAD_WAIT_TIMEOUT=20_000; @Rule public ActivityScenarioRule activityScenarioRule=new ActivityScenarioRule<>(MainActivity.class); @@ -84,14 +85,14 @@ public class StoreScreenshotsGenerator{ AccountSession session=AccountSessionManager.getInstance().getAccount(AccountSessionManager.getInstance().getLastActiveAccountID()); MastodonApp.context.deleteDatabase(session.getID()+".db"); - onView(isRoot()).perform(waitId(R.id.more, 5000)); + onView(isRoot()).perform(waitId(R.id.more, LOAD_WAIT_TIMEOUT)); Thread.sleep(500); takeScreenshot("HomeTimeline"); GlobalUserPreferences.theme=GlobalUserPreferences.ThemePreference.DARK; activityScenarioRule.getScenario().recreate(); - onView(isRoot()).perform(waitId(R.id.more, 5000)); + onView(isRoot()).perform(waitId(R.id.more, LOAD_WAIT_TIMEOUT)); Thread.sleep(500); takeScreenshot("HomeTimeline_Dark"); @@ -100,8 +101,8 @@ public class StoreScreenshotsGenerator{ activityScenarioRule.getScenario().onActivity(activity->UiUtils.openProfileByID(activity, session.getID(), args.getString("profileAccountID"))); Thread.sleep(500); - onView(isRoot()).perform(waitId(R.id.avatar_border, 5000)); // wait for profile to load - onView(isRoot()).perform(waitId(R.id.more, 5000)); // wait for timeline to load + onView(isRoot()).perform(waitId(R.id.avatar_border, LOAD_WAIT_TIMEOUT)); // wait for profile to load + onView(isRoot()).perform(waitId(R.id.more, LOAD_WAIT_TIMEOUT)); // wait for timeline to load Thread.sleep(500); takeScreenshot("Profile"); 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 index 63c3fb866..4728a4861 100644 --- a/mastodon/src/androidTest/java/org/joinmastodon/android/ui/utils/UiUtilsTest.java +++ b/mastodon/src/androidTest/java/org/joinmastodon/android/ui/utils/UiUtilsTest.java @@ -2,15 +2,22 @@ package org.joinmastodon.android.ui.utils; import static org.junit.Assert.*; +import android.content.Context; +import android.content.res.Resources; import android.util.Pair; +import org.joinmastodon.android.MastodonApp; import org.joinmastodon.android.api.session.AccountSessionManager; import org.joinmastodon.android.model.Account; +import org.joinmastodon.android.model.AccountField; import org.joinmastodon.android.model.Instance; import org.junit.AfterClass; import org.junit.BeforeClass; import org.junit.Test; +import java.util.Arrays; +import java.util.List; +import java.util.Locale; import java.util.Optional; public class UiUtilsTest { @@ -103,4 +110,152 @@ public class UiUtilsTest { "somewhere.else" )); } + + private final String[] args = new String[] { "Megalodon", "♡" }; + + private String gen(String format, CharSequence... args) { + return UiUtils.generateFormattedString(format, args).toString(); + } + @Test + public void generateFormattedString() { + assertEquals( + "ordered substitution", + "Megalodon reacted with ♡", + gen("%s reacted with %s", args) + ); + + assertEquals( + "1 2 3 4 5", + gen("%s %s %s %s %s", "1", "2", "3", "4", "5") + ); + + assertEquals( + "indexed substitution", + "with ♡ was reacted by Megalodon", + gen("with %2$s was reacted by %1$s", args) + ); + + assertEquals( + "indexed substitution, in order", + "Megalodon reacted with ♡", + gen("%1$s reacted with %2$s", args) + ); + + assertEquals( + "indexed substitution, 0-based", + "Megalodon reacted with ♡", + gen("%0$s reacted with %1$s", args) + ); + + assertEquals( + "indexed substitution, 5 items", + "5 4 3 2 1", + gen("%5$s %4$s %3$s %2$s %1$s", "1", "2", "3", "4", "5") + ); + + assertEquals( + "one argument missing", + "Megalodon reacted with ♡", + gen("reacted with %s", args) + ); + + assertEquals( + "multiple arguments missing", + "Megalodon reacted with ♡", + gen("reacted with", args) + ); + + assertEquals( + "multiple arguments missing, numbers in expeced positions", + "1 2 x 3 4 5", + gen("%s x %s", "1", "2", "3", "4", "5") + ); + + assertEquals( + "one leading and trailing space", + "Megalodon reacted with ♡", + gen(" reacted with ", args) + ); + + assertEquals( + "multiple leading and trailing spaces", + "Megalodon reacted with ♡", + gen(" reacted with ", args) + ); + + assertEquals( + "invalid format produces expected invalid result", + "Megalodon reacted with % s ♡", + gen("reacted with % s", args) + ); + + assertEquals( + "plain string as format, all arguments get added", + "a x b c", + gen("x", new String[] { "a", "b", "c" }) + ); + + assertEquals("empty input produces empty output", "", gen("")); + + // not supported: +// assertEquals("a b a", gen("%1$s %2$s %2$s %1$s", new String[] { "a", "b", "c" })); +// assertEquals("x", gen("%s %1$s %2$s %1$s %s", new String[] { "a", "b", "c" })); + } + + private AccountField makeField(String name, String value) { + AccountField f = new AccountField(); + f.name = name; + f.value = value; + return f; + } + + private Account fakeAccount(AccountField... fields) { + Account a = new Account(); + a.fields = Arrays.asList(fields); + return a; + } + + @Test + public void extractPronouns() { + assertEquals("they", UiUtils.extractPronouns(MastodonApp.context, fakeAccount( + makeField("name and pronouns", "https://pronouns.site"), + makeField("pronouns", "they"), + makeField("pronouns something", "bla bla") + )).orElseThrow()); + + assertTrue(UiUtils.extractPronouns(MastodonApp.context, fakeAccount()).isEmpty()); + + assertEquals("it/its", UiUtils.extractPronouns(MastodonApp.context, fakeAccount( + makeField("pronouns pronouns pronouns", "hi hi hi"), + makeField("pronouns", "it/its"), + makeField("the pro's nouns", "professional") + )).orElseThrow()); + + assertEquals("she/he", UiUtils.extractPronouns(MastodonApp.context, fakeAccount( + makeField("my name is", "jeanette shork, apparently"), + makeField("my pronouns are", "she/he") + )).orElseThrow()); + + assertEquals("they/them", UiUtils.extractPronouns(MastodonApp.context, fakeAccount( + makeField("pronouns", "https://pronouns.cc/pronouns/they/them") + )).orElseThrow()); + + Context german = UiUtils.getLocalizedContext(MastodonApp.context, Locale.GERMAN); + + assertEquals("sie/ihr", UiUtils.extractPronouns(german, fakeAccount( + makeField("pronomen lauten", "sie/ihr"), + makeField("pronouns are", "she/her"), + makeField("die pronomen", "stehen oben") + )).orElseThrow()); + + assertEquals("er/ihm", UiUtils.extractPronouns(german, fakeAccount( + makeField("die pronomen", "stehen unten"), + makeField("pronomen sind", "er/ihm"), + makeField("pronouns are", "he/him") + )).orElseThrow()); + + assertEquals("* (asterisk)", UiUtils.extractPronouns(MastodonApp.context, fakeAccount( + makeField("pronouns", "-- * (asterisk) --") + )).orElseThrow()); + } } \ No newline at end of file diff --git a/mastodon/src/androidTest/java/org/joinmastodon/android/utils/StatusFilterPredicateTest.java b/mastodon/src/androidTest/java/org/joinmastodon/android/utils/StatusFilterPredicateTest.java index 0a00fc665..84f156706 100644 --- a/mastodon/src/androidTest/java/org/joinmastodon/android/utils/StatusFilterPredicateTest.java +++ b/mastodon/src/androidTest/java/org/joinmastodon/android/utils/StatusFilterPredicateTest.java @@ -1,10 +1,10 @@ package org.joinmastodon.android.utils; -import static org.joinmastodon.android.model.Filter.FilterAction.*; -import static org.joinmastodon.android.model.Filter.FilterContext.*; +import static org.joinmastodon.android.model.FilterAction.*; +import static org.joinmastodon.android.model.FilterContext.*; import static org.junit.Assert.*; -import org.joinmastodon.android.model.Filter; +import org.joinmastodon.android.model.LegacyFilter; import org.joinmastodon.android.model.Status; import org.junit.Test; @@ -14,8 +14,8 @@ import java.util.List; public class StatusFilterPredicateTest { - private static final Filter hideMeFilter = new Filter(), warnMeFilter = new Filter(); - private static final List allFilters = List.of(hideMeFilter, warnMeFilter); + private static final LegacyFilter hideMeFilter = new LegacyFilter(), warnMeFilter = new LegacyFilter(); + private static final List allFilters = List.of(hideMeFilter, warnMeFilter); private static final Status hideInHomePublic = Status.ofFake(null, "hide me, please", Instant.now()), diff --git a/mastodon/src/github/java/org/joinmastodon/android/updater/GithubSelfUpdaterImpl.java b/mastodon/src/github/java/org/joinmastodon/android/updater/GithubSelfUpdaterImpl.java index a186b952c..caced56a9 100644 --- a/mastodon/src/github/java/org/joinmastodon/android/updater/GithubSelfUpdaterImpl.java +++ b/mastodon/src/github/java/org/joinmastodon/android/updater/GithubSelfUpdaterImpl.java @@ -100,8 +100,8 @@ public class GithubSelfUpdaterImpl extends GithubSelfUpdater{ public void maybeCheckForUpdates(){ if(state!=UpdateState.NO_UPDATE && state!=UpdateState.UPDATE_AVAILABLE) return; - long timeSinceLastCheck=System.currentTimeMillis()-getPrefs().getLong("lastCheck", CHECK_PERIOD); - if(timeSinceLastCheck>=CHECK_PERIOD){ + long timeSinceLastCheck=System.currentTimeMillis()-getPrefs().getLong("lastCheck", 0); + if(timeSinceLastCheck>CHECK_PERIOD || forceUpdate){ setState(UpdateState.CHECKING); MastodonAPIController.runInBackground(this::actuallyCheckForUpdates); } @@ -148,7 +148,8 @@ public class GithubSelfUpdaterImpl extends GithubSelfUpdater{ curForkNumber=Integer.parseInt(matcher.group(4)); long newVersion=((long)newMajor << 32) | ((long)newMinor << 16) | newRevision; long curVersion=((long)curMajor << 32) | ((long)curMinor << 16) | curRevision; - if(newVersion>curVersion || newForkNumber>curForkNumber){ + if(newVersion>curVersion || newForkNumber>curForkNumber || forceUpdate){ + forceUpdate=false; String version=newMajor+"."+newMinor+"."+newRevision+"+fork."+newForkNumber; Log.d(TAG, "actuallyCheckForUpdates: new version: "+version); for(JsonElement el:obj.getAsJsonArray("assets")){ @@ -323,6 +324,15 @@ public class GithubSelfUpdaterImpl extends GithubSelfUpdater{ } } + @Override + public void reset(){ + getPrefs().edit().clear().apply(); + File apk=getUpdateApkFile(); + if(apk.exists()) + apk.delete(); + state=UpdateState.NO_UPDATE; + } + /*public static class InstallerStatusReceiver extends BroadcastReceiver{ @Override diff --git a/mastodon/src/main/AndroidManifest.xml b/mastodon/src/main/AndroidManifest.xml index d7c114c33..b56d7f75c 100644 --- a/mastodon/src/main/AndroidManifest.xml +++ b/mastodon/src/main/AndroidManifest.xml @@ -28,7 +28,6 @@ android:supportsRtl="true" android:localeConfig="@xml/locales_config" android:icon="@mipmap/ic_launcher" - android:roundIcon="@mipmap/ic_launcher_round" android:theme="@style/Theme.Mastodon.AutoLightDark" android:largeHeap="true"> @@ -37,6 +36,18 @@ + + + + + + + + + + + + + + + + + +{{content}} + + \ No newline at end of file diff --git a/mastodon/src/main/java/org/joinmastodon/android/AudioPlayerService.java b/mastodon/src/main/java/org/joinmastodon/android/AudioPlayerService.java index ab0c29d1d..4a01c13f0 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/AudioPlayerService.java +++ b/mastodon/src/main/java/org/joinmastodon/android/AudioPlayerService.java @@ -31,7 +31,6 @@ import org.joinmastodon.android.ui.text.HtmlParser; import org.parceler.Parcels; import java.io.IOException; -import java.util.ArrayList; import java.util.HashSet; import androidx.annotation.Nullable; @@ -57,6 +56,7 @@ public class AudioPlayerService extends Service{ private static HashSet callbacks=new HashSet<>(); private AudioManager.OnAudioFocusChangeListener audioFocusChangeListener=this::onAudioFocusChanged; private boolean resumeAfterAudioFocusGain; + private boolean isBuffering=true; private BroadcastReceiver receiver=new BroadcastReceiver(){ @Override @@ -176,6 +176,7 @@ public class AudioPlayerService extends Service{ player.setOnErrorListener(this::onPlayerError); player.setOnCompletionListener(this::onPlayerCompletion); player.setOnSeekCompleteListener(this::onPlayerSeekCompleted); + player.setOnInfoListener(this::onPlayerInfo); try{ player.setDataSource(this, Uri.parse(attachment.url)); player.prepareAsync(); @@ -187,7 +188,9 @@ public class AudioPlayerService extends Service{ } private void onPlayerPrepared(MediaPlayer mp){ + Log.i(TAG, "onPlayerPrepared"); playerReady=true; + isBuffering=false; player.start(); updateSessionState(false); } @@ -205,6 +208,21 @@ public class AudioPlayerService extends Service{ stopSelf(); } + private boolean onPlayerInfo(MediaPlayer mp, int what, int extra){ + switch(what){ + case MediaPlayer.MEDIA_INFO_BUFFERING_START -> { + isBuffering=true; + updateSessionState(false); + } + case MediaPlayer.MEDIA_INFO_BUFFERING_END -> { + isBuffering=false; + updateSessionState(false); + } + default -> Log.i(TAG, "onPlayerInfo() called with: mp = ["+mp+"], what = ["+what+"], extra = ["+extra+"]"); + } + return true; + } + private void onAudioFocusChanged(int change){ switch(change){ case AudioManager.AUDIOFOCUS_LOSS -> { @@ -212,7 +230,7 @@ public class AudioPlayerService extends Service{ pause(false); } case AudioManager.AUDIOFOCUS_LOSS_TRANSIENT -> { - resumeAfterAudioFocusGain=true; + resumeAfterAudioFocusGain=isPlaying(); pause(false); } case AudioManager.AUDIOFOCUS_GAIN -> { @@ -232,12 +250,16 @@ public class AudioPlayerService extends Service{ private void updateSessionState(boolean removeNotification){ session.setPlaybackState(new PlaybackState.Builder() - .setState(player.isPlaying() ? PlaybackState.STATE_PLAYING : PlaybackState.STATE_PAUSED, player.getCurrentPosition(), 1f) + .setState(switch(getPlayState()){ + case PLAYING -> PlaybackState.STATE_PLAYING; + case PAUSED -> PlaybackState.STATE_PAUSED; + case BUFFERING -> PlaybackState.STATE_BUFFERING; + }, player.getCurrentPosition(), 1f) .setActions(PlaybackState.ACTION_STOP | PlaybackState.ACTION_PLAY_PAUSE | PlaybackState.ACTION_SEEK_TO) .build()); updateNotification(!player.isPlaying(), removeNotification); for(Callback cb:callbacks) - cb.onPlayStateChanged(attachment.id, player.isPlaying(), player.getCurrentPosition()); + cb.onPlayStateChanged(attachment.id, getPlayState(), player.getCurrentPosition()); } private void updateNotification(boolean dismissable, boolean removeNotification){ @@ -310,6 +332,12 @@ public class AudioPlayerService extends Service{ return attachment.id; } + public PlayState getPlayState(){ + if(isBuffering) + return PlayState.BUFFERING; + return player.isPlaying() ? PlayState.PLAYING : PlayState.PAUSED; + } + public static void registerCallback(Callback cb){ callbacks.add(cb); } @@ -333,7 +361,13 @@ public class AudioPlayerService extends Service{ } public interface Callback{ - void onPlayStateChanged(String attachmentID, boolean playing, int position); + void onPlayStateChanged(String attachmentID, PlayState state, int position); void onPlaybackStopped(String attachmentID); } + + public enum PlayState{ + PLAYING, + PAUSED, + BUFFERING + } } diff --git a/mastodon/src/main/java/org/joinmastodon/android/GlobalUserPreferences.java b/mastodon/src/main/java/org/joinmastodon/android/GlobalUserPreferences.java index 8167f6ecc..26d8f792d 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/GlobalUserPreferences.java +++ b/mastodon/src/main/java/org/joinmastodon/android/GlobalUserPreferences.java @@ -4,136 +4,113 @@ import static org.joinmastodon.android.api.MastodonAPIController.gson; import android.content.Context; import android.content.SharedPreferences; +import android.util.Log; + +import androidx.annotation.StringRes; import com.google.gson.JsonSyntaxException; import com.google.gson.reflect.TypeToken; +import org.joinmastodon.android.api.session.AccountLocalPreferences; +import org.joinmastodon.android.api.session.AccountSession; +import org.joinmastodon.android.api.session.AccountSessionManager; import org.joinmastodon.android.model.ContentType; +import org.joinmastodon.android.model.Instance; import org.joinmastodon.android.model.TimelineDefinition; import java.lang.reflect.Type; +import java.util.ArrayList; import java.util.HashMap; import java.util.HashSet; -import java.util.List; import java.util.Map; import java.util.Set; public class GlobalUserPreferences{ + private static final String TAG="GlobalUserPreferences"; + public static boolean playGifs; public static boolean useCustomTabs; + public static boolean altTextReminders, confirmUnfollow, confirmBoost, confirmDeletePost; + public static ThemePreference theme; + + // MEGALODON public static boolean trueBlackTheme; - public static boolean showReplies; - public static boolean showBoosts; public static boolean loadNewPosts; public static boolean showNewPostsButton; - public static boolean showInteractionCounts; - public static boolean alwaysExpandContentWarnings; - public static boolean disableMarquee; + public static boolean toolbarMarquee; public static boolean disableSwipe; public static boolean voteButtonForSingleChoice; public static boolean enableDeleteNotifications; public static boolean translateButtonOpenedOnly; public static boolean uniformNotificationIcon; public static boolean reduceMotion; - public static boolean keepOnlyLatestNotification; - public static boolean disableAltTextReminder; public static boolean showAltIndicator; public static boolean showNoAltIndicator; public static boolean enablePreReleases; public static PrefixRepliesMode prefixReplies; - public static boolean bottomEncoding; public static boolean collapseLongPosts; public static boolean spectatorMode; public static boolean autoHideFab; - public static boolean replyLineAboveHeader; public static boolean compactReblogReplyLine; - public static boolean confirmBeforeReblog; public static boolean allowRemoteLoading; public static boolean forwardReportDefault; public static AutoRevealMode autoRevealEqualSpoilers; - public static String publishButtonText; - public static ThemePreference theme; public static ColorPreference color; - - public static Map> recentLanguages; - public static Map> pinnedTimelines; - public static Set accountsWithLocalOnlySupport; - public static Set accountsInGlitchMode; - public static Set accountsWithContentTypesEnabled; - public static Map accountsDefaultContentTypes; - - private final static Type recentLanguagesType = new TypeToken>>() {}.getType(); - private final static Type pinnedTimelinesType = new TypeToken>>() {}.getType(); - private final static Type accountsDefaultContentTypesType = new TypeToken>() {}.getType(); - - /** - * Pleroma - */ - public static String replyVisibility; + public static boolean disableM3PillActiveIndicator; private static SharedPreferences getPrefs(){ return MastodonApp.context.getSharedPreferences("global", Context.MODE_PRIVATE); } - private static T fromJson(String json, Type type, T orElse) { - if (json == null) return orElse; - try { return gson.fromJson(json, type); } - catch (JsonSyntaxException ignored) { return orElse; } + public static T fromJson(String json, Type type, T orElse){ + if(json==null) return orElse; + try{ + T value=gson.fromJson(json, type); + return value==null ? orElse : value; + }catch(JsonSyntaxException ignored){ + return orElse; + } } - public static void removeAccount(String accountId) { - recentLanguages.remove(accountId); - pinnedTimelines.remove(accountId); - accountsInGlitchMode.remove(accountId); - accountsWithLocalOnlySupport.remove(accountId); - accountsWithContentTypesEnabled.remove(accountId); - accountsDefaultContentTypes.remove(accountId); - save(); + public static > T enumValue(Class enumType, String name) { + try { return Enum.valueOf(enumType, name); } + catch (NullPointerException npe) { return null; } } public static void load(){ SharedPreferences prefs=getPrefs(); + playGifs=prefs.getBoolean("playGifs", true); useCustomTabs=prefs.getBoolean("useCustomTabs", true); + theme=ThemePreference.values()[prefs.getInt("theme", 0)]; + altTextReminders=prefs.getBoolean("altTextReminders", true); + confirmUnfollow=prefs.getBoolean("confirmUnfollow", true); + confirmBoost=prefs.getBoolean("confirmBoost", false); + confirmDeletePost=prefs.getBoolean("confirmDeletePost", true); + + // MEGALODON trueBlackTheme=prefs.getBoolean("trueBlackTheme", false); - showReplies=prefs.getBoolean("showReplies", true); - showBoosts=prefs.getBoolean("showBoosts", true); loadNewPosts=prefs.getBoolean("loadNewPosts", true); showNewPostsButton=prefs.getBoolean("showNewPostsButton", true); - showInteractionCounts=prefs.getBoolean("showInteractionCounts", false); - alwaysExpandContentWarnings=prefs.getBoolean("alwaysExpandContentWarnings", false); - disableMarquee=prefs.getBoolean("disableMarquee", false); + toolbarMarquee=prefs.getBoolean("toolbarMarquee", true); disableSwipe=prefs.getBoolean("disableSwipe", false); voteButtonForSingleChoice=prefs.getBoolean("voteButtonForSingleChoice", true); enableDeleteNotifications=prefs.getBoolean("enableDeleteNotifications", false); translateButtonOpenedOnly=prefs.getBoolean("translateButtonOpenedOnly", false); uniformNotificationIcon=prefs.getBoolean("uniformNotificationIcon", false); reduceMotion=prefs.getBoolean("reduceMotion", false); - keepOnlyLatestNotification=prefs.getBoolean("keepOnlyLatestNotification", false); - disableAltTextReminder=prefs.getBoolean("disableAltTextReminder", false); showAltIndicator=prefs.getBoolean("showAltIndicator", true); showNoAltIndicator=prefs.getBoolean("showNoAltIndicator", true); enablePreReleases=prefs.getBoolean("enablePreReleases", false); prefixReplies=PrefixRepliesMode.valueOf(prefs.getString("prefixReplies", PrefixRepliesMode.NEVER.name())); - bottomEncoding=prefs.getBoolean("bottomEncoding", false); collapseLongPosts=prefs.getBoolean("collapseLongPosts", true); spectatorMode=prefs.getBoolean("spectatorMode", false); autoHideFab=prefs.getBoolean("autoHideFab", true); - replyLineAboveHeader=prefs.getBoolean("replyLineAboveHeader", true); compactReblogReplyLine=prefs.getBoolean("compactReblogReplyLine", true); - confirmBeforeReblog=prefs.getBoolean("confirmBeforeReblog", false); - publishButtonText=prefs.getString("publishButtonText", ""); - theme=ThemePreference.values()[prefs.getInt("theme", 0)]; - recentLanguages=fromJson(prefs.getString("recentLanguages", null), recentLanguagesType, new HashMap<>()); - pinnedTimelines=fromJson(prefs.getString("pinnedTimelines", null), pinnedTimelinesType, new HashMap<>()); - accountsWithLocalOnlySupport=prefs.getStringSet("accountsWithLocalOnlySupport", new HashSet<>()); - accountsInGlitchMode=prefs.getStringSet("accountsInGlitchMode", new HashSet<>()); - replyVisibility=prefs.getString("replyVisibility", null); - accountsWithContentTypesEnabled=prefs.getStringSet("accountsWithContentTypesEnabled", new HashSet<>()); - accountsDefaultContentTypes=fromJson(prefs.getString("accountsDefaultContentTypes", null), accountsDefaultContentTypesType, new HashMap<>()); allowRemoteLoading=prefs.getBoolean("allowRemoteLoading", true); autoRevealEqualSpoilers=AutoRevealMode.valueOf(prefs.getString("autoRevealEqualSpoilers", AutoRevealMode.THREADS.name())); forwardReportDefault=prefs.getBoolean("forwardReportDefault", true); + disableM3PillActiveIndicator=prefs.getBoolean("disableM3PillActiveIndicator", false); if (prefs.contains("prefixRepliesWithRe")) { prefixReplies = prefs.getBoolean("prefixRepliesWithRe", false) @@ -150,27 +127,30 @@ public class GlobalUserPreferences{ // invalid color name or color was previously saved as integer color=ColorPreference.PINK; } + + if(prefs.getInt("migrationLevel", 0) < 61) migrateToUpstreamVersion61(); } public static void save(){ getPrefs().edit() .putBoolean("playGifs", playGifs) .putBoolean("useCustomTabs", useCustomTabs) - .putBoolean("showReplies", showReplies) - .putBoolean("showBoosts", showBoosts) + .putInt("theme", theme.ordinal()) + .putBoolean("altTextReminders", altTextReminders) + .putBoolean("confirmUnfollow", confirmUnfollow) + .putBoolean("confirmBoost", confirmBoost) + .putBoolean("confirmDeletePost", confirmDeletePost) + + // MEGALODON .putBoolean("loadNewPosts", loadNewPosts) .putBoolean("showNewPostsButton", showNewPostsButton) .putBoolean("trueBlackTheme", trueBlackTheme) - .putBoolean("showInteractionCounts", showInteractionCounts) - .putBoolean("alwaysExpandContentWarnings", alwaysExpandContentWarnings) - .putBoolean("disableMarquee", disableMarquee) + .putBoolean("toolbarMarquee", toolbarMarquee) .putBoolean("disableSwipe", disableSwipe) .putBoolean("enableDeleteNotifications", enableDeleteNotifications) .putBoolean("translateButtonOpenedOnly", translateButtonOpenedOnly) .putBoolean("uniformNotificationIcon", uniformNotificationIcon) .putBoolean("reduceMotion", reduceMotion) - .putBoolean("keepOnlyLatestNotification", keepOnlyLatestNotification) - .putBoolean("disableAltTextReminder", disableAltTextReminder) .putBoolean("showAltIndicator", showAltIndicator) .putBoolean("showNoAltIndicator", showNoAltIndicator) .putBoolean("enablePreReleases", enablePreReleases) @@ -179,25 +159,64 @@ public class GlobalUserPreferences{ .putBoolean("spectatorMode", spectatorMode) .putBoolean("autoHideFab", autoHideFab) .putBoolean("compactReblogReplyLine", compactReblogReplyLine) - .putString("publishButtonText", publishButtonText) - .putBoolean("bottomEncoding", bottomEncoding) - .putBoolean("replyLineAboveHeader", replyLineAboveHeader) - .putBoolean("confirmBeforeReblog", confirmBeforeReblog) - .putInt("theme", theme.ordinal()) .putString("color", color.name()) - .putString("recentLanguages", gson.toJson(recentLanguages)) - .putString("pinnedTimelines", gson.toJson(pinnedTimelines)) - .putStringSet("accountsWithLocalOnlySupport", accountsWithLocalOnlySupport) - .putStringSet("accountsInGlitchMode", accountsInGlitchMode) - .putString("replyVisibility", replyVisibility) - .putStringSet("accountsWithContentTypesEnabled", accountsWithContentTypesEnabled) - .putString("accountsDefaultContentTypes", gson.toJson(accountsDefaultContentTypes)) .putBoolean("allowRemoteLoading", allowRemoteLoading) .putString("autoRevealEqualSpoilers", autoRevealEqualSpoilers.name()) .putBoolean("forwardReportDefault", forwardReportDefault) + .putBoolean("disableM3PillActiveIndicator", disableM3PillActiveIndicator) .apply(); } + private static void migrateToUpstreamVersion61(){ + Log.d(TAG, "Migrating preferences to upstream version 61!!"); + + Type accountsDefaultContentTypesType = new TypeToken>() {}.getType(); + Type pinnedTimelinesType = new TypeToken>>() {}.getType(); + Type recentLanguagesType = new TypeToken>>() {}.getType(); + + // migrate global preferences + SharedPreferences prefs=getPrefs(); + altTextReminders=!prefs.getBoolean("disableAltTextReminder", false); + confirmBoost=prefs.getBoolean("confirmBeforeReblog", false); + toolbarMarquee=!prefs.getBoolean("disableMarquee", false); + + save(); + + // migrate local preferences + AccountSessionManager asm=AccountSessionManager.getInstance(); + // reset: Set accountsWithContentTypesEnabled=prefs.getStringSet("accountsWithContentTypesEnabled", new HashSet<>()); + Map accountsDefaultContentTypes=fromJson(prefs.getString("accountsDefaultContentTypes", null), accountsDefaultContentTypesType, new HashMap<>()); + Map> pinnedTimelines=fromJson(prefs.getString("pinnedTimelines", null), pinnedTimelinesType, new HashMap<>()); + Set accountsWithLocalOnlySupport=prefs.getStringSet("accountsWithLocalOnlySupport", new HashSet<>()); + Set accountsInGlitchMode=prefs.getStringSet("accountsInGlitchMode", new HashSet<>()); + Map> recentLanguages=fromJson(prefs.getString("recentLanguages", null), recentLanguagesType, new HashMap<>()); + + for(AccountSession session : asm.getLoggedInAccounts()){ + String accountID=session.getID(); + AccountLocalPreferences localPrefs=session.getLocalPreferences(); + localPrefs.revealCWs=prefs.getBoolean("alwaysExpandContentWarnings", false); + localPrefs.recentLanguages=recentLanguages.get(accountID); + // reset: localPrefs.contentTypesEnabled=accountsWithContentTypesEnabled.contains(accountID); + localPrefs.defaultContentType=accountsDefaultContentTypes.getOrDefault(accountID, ContentType.PLAIN); + localPrefs.showInteractionCounts=prefs.getBoolean("showInteractionCounts", false); + localPrefs.timelines=pinnedTimelines.getOrDefault(accountID, TimelineDefinition.getDefaultTimelines(accountID)); + localPrefs.localOnlySupported=accountsWithLocalOnlySupport.contains(accountID); + localPrefs.glitchInstance=accountsInGlitchMode.contains(accountID); + localPrefs.publishButtonText=prefs.getString("publishButtonText", null); + localPrefs.keepOnlyLatestNotification=prefs.getBoolean("keepOnlyLatestNotification", false); + localPrefs.showReplies=prefs.getBoolean("showReplies", true); + localPrefs.showBoosts=prefs.getBoolean("showBoosts", true); + + if(session.getInstance().map(Instance::isAkkoma).orElse(false)){ + localPrefs.timelineReplyVisibility=prefs.getString("replyVisibility", null); + } + + localPrefs.save(); + } + + prefs.edit().putInt("migrationLevel", 61).apply(); + } + public enum ColorPreference{ MATERIAL3, PINK, @@ -206,7 +225,20 @@ public class GlobalUserPreferences{ BLUE, BROWN, RED, - YELLOW + YELLOW; + + public @StringRes int getName() { + return switch(this){ + case MATERIAL3 -> R.string.sk_color_palette_material3; + case PINK -> R.string.sk_color_palette_pink; + case PURPLE -> R.string.sk_color_palette_purple; + case GREEN -> R.string.sk_color_palette_green; + case BLUE -> R.string.sk_color_palette_blue; + case BROWN -> R.string.sk_color_palette_brown; + case RED -> R.string.sk_color_palette_red; + case YELLOW -> R.string.sk_color_palette_yellow; + }; + } } public enum ThemePreference{ diff --git a/mastodon/src/main/java/org/joinmastodon/android/MainActivity.java b/mastodon/src/main/java/org/joinmastodon/android/MainActivity.java index 7af4b8fdb..5abcf743d 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/MainActivity.java +++ b/mastodon/src/main/java/org/joinmastodon/android/MainActivity.java @@ -5,13 +5,16 @@ import android.app.Fragment; import android.app.assist.AssistContent; import android.content.Intent; import android.content.pm.PackageManager; +import android.net.Uri; import android.os.Build; import android.os.Bundle; import android.util.Log; import android.view.View; import android.widget.FrameLayout; +import android.widget.Toast; import org.joinmastodon.android.api.ObjectValidationException; +import org.joinmastodon.android.api.requests.search.GetSearchResults; import org.joinmastodon.android.api.session.AccountSession; import org.joinmastodon.android.api.session.AccountSessionManager; import org.joinmastodon.android.fragments.ComposeFragment; @@ -21,6 +24,7 @@ import org.joinmastodon.android.fragments.ThreadFragment; import org.joinmastodon.android.fragments.onboarding.AccountActivationFragment; import org.joinmastodon.android.fragments.onboarding.CustomWelcomeFragment; import org.joinmastodon.android.model.Notification; +import org.joinmastodon.android.model.SearchResults; import org.joinmastodon.android.ui.utils.UiUtils; import org.joinmastodon.android.updater.GithubSelfUpdater; import org.joinmastodon.android.utils.ProvidesAssistContent; @@ -28,6 +32,9 @@ import org.parceler.Parcels; import androidx.annotation.Nullable; import me.grishka.appkit.FragmentStackActivity; +import me.grishka.appkit.Nav; +import me.grishka.appkit.api.Callback; +import me.grishka.appkit.api.ErrorResponse; public class MainActivity extends FragmentStackActivity implements ProvidesAssistContent { @Override @@ -73,6 +80,8 @@ public class MainActivity extends FragmentStackActivity implements ProvidesAssis showFragmentForNotification(notification, session.getID()); } else if (intent.getBooleanExtra("compose", false)){ showCompose(); + } else if (Intent.ACTION_VIEW.equals(intent.getAction())){ + handleURL(intent.getData(), null); } else { showFragmentClearingBackStack(fragment); maybeRequestNotificationsPermission(); @@ -111,11 +120,55 @@ public class MainActivity extends FragmentStackActivity implements ProvidesAssis } }else if(intent.getBooleanExtra("compose", false)){ showCompose(); + }else if(Intent.ACTION_VIEW.equals(intent.getAction())){ + handleURL(intent.getData(), null); }/*else if(intent.hasExtra(PackageInstaller.EXTRA_STATUS) && GithubSelfUpdater.needSelfUpdating()){ GithubSelfUpdater.getInstance().handleIntentFromInstaller(intent, this); }*/ } + public void handleURL(Uri uri, String accountID){ + if(uri==null) + return; + if(!"https".equals(uri.getScheme()) && !"http".equals(uri.getScheme())) + return; + AccountSession session; + if(accountID==null) + session=AccountSessionManager.getInstance().getLastActiveAccount(); + else + session=AccountSessionManager.get(accountID); + if(session==null || !session.activated) + return; + openSearchQuery(uri.toString(), session.getID(), R.string.opening_link, false); + } + + public void openSearchQuery(String q, String accountID, int progressText, boolean fromSearch){ + new GetSearchResults(q, null, true) + .setCallback(new Callback<>(){ + @Override + public void onSuccess(SearchResults result){ + Bundle args=new Bundle(); + args.putString("account", accountID); + if(result.statuses!=null && !result.statuses.isEmpty()){ + args.putParcelable("status", Parcels.wrap(result.statuses.get(0))); + Nav.go(MainActivity.this, ThreadFragment.class, args); + }else if(result.accounts!=null && !result.accounts.isEmpty()){ + args.putParcelable("profileAccount", Parcels.wrap(result.accounts.get(0))); + Nav.go(MainActivity.this, ProfileFragment.class, args); + }else{ + Toast.makeText(MainActivity.this, fromSearch ? R.string.no_search_results : R.string.link_not_supported, Toast.LENGTH_SHORT).show(); + } + } + + @Override + public void onError(ErrorResponse error){ + error.showToast(MainActivity.this); + } + }) + .wrapProgress(this, progressText, true) + .exec(accountID); + } + private void showFragmentForNotification(Notification notification, String accountID){ try{ notification.postprocess(); diff --git a/mastodon/src/main/java/org/joinmastodon/android/MastodonApp.java b/mastodon/src/main/java/org/joinmastodon/android/MastodonApp.java index 8561fd7f2..61cd8deed 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/MastodonApp.java +++ b/mastodon/src/main/java/org/joinmastodon/android/MastodonApp.java @@ -3,6 +3,7 @@ package org.joinmastodon.android; import android.annotation.SuppressLint; import android.app.Application; import android.content.Context; +import android.webkit.WebView; import org.joinmastodon.android.api.PushSubscriptionManager; @@ -28,5 +29,8 @@ public class MastodonApp extends Application{ PushSubscriptionManager.tryRegisterFCM(); GlobalUserPreferences.load(); + if(BuildConfig.DEBUG){ + WebView.setWebContentsDebuggingEnabled(true); + } } } diff --git a/mastodon/src/main/java/org/joinmastodon/android/PushNotificationReceiver.java b/mastodon/src/main/java/org/joinmastodon/android/PushNotificationReceiver.java index c7da94a11..915b4d91e 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/PushNotificationReceiver.java +++ b/mastodon/src/main/java/org/joinmastodon/android/PushNotificationReceiver.java @@ -25,7 +25,6 @@ import org.joinmastodon.android.api.requests.statuses.SetStatusFavorited; import org.joinmastodon.android.api.requests.statuses.SetStatusReblogged; import org.joinmastodon.android.api.session.AccountSession; import org.joinmastodon.android.api.session.AccountSessionManager; -import org.joinmastodon.android.events.NotificationReceivedEvent; import org.joinmastodon.android.model.Account; import org.joinmastodon.android.model.Mention; import org.joinmastodon.android.model.NotificationAction; @@ -38,7 +37,9 @@ import org.parceler.Parcels; import java.util.ArrayList; import java.util.Arrays; +import java.util.HashMap; import java.util.List; +import java.util.Map; import java.util.Random; import java.util.UUID; import java.util.stream.Collectors; @@ -57,6 +58,7 @@ public class PushNotificationReceiver extends BroadcastReceiver{ private static final int SUMMARY_ID = 791; private static int notificationId = 0; + private static Map notificationIdsForAccounts = new HashMap<>(); @Override public void onReceive(Context context, Intent intent){ @@ -88,9 +90,12 @@ public class PushNotificationReceiver extends BroadcastReceiver{ Log.w(TAG, "onReceive: account for id '"+pushAccountID+"' not found"); return; } + if(account.getLocalPreferences().getNotificationsPauseEndTime()>System.currentTimeMillis()){ + Log.i(TAG, "onReceive: dropping notification because user has paused notifications for this account"); + return; + } String accountID=account.getID(); PushNotification pn=AccountSessionManager.getInstance().getAccount(accountID).getPushSubscriptionManager().decryptNotification(k, p, s); - E.post(new NotificationReceivedEvent(accountID, pn.notificationId+"")); new GetNotificationByID(pn.notificationId+"") .setCallback(new Callback<>(){ @Override @@ -145,7 +150,8 @@ public class PushNotificationReceiver extends BroadcastReceiver{ private void notify(Context context, PushNotification pn, String accountID, org.joinmastodon.android.model.Notification notification){ NotificationManager nm=context.getSystemService(NotificationManager.class); - Account self=AccountSessionManager.getInstance().getAccount(accountID).self; + AccountSession session=AccountSessionManager.get(accountID); + Account self=session.self; String accountName="@"+self.username+"@"+AccountSessionManager.getInstance().getAccount(accountID).domain; Notification.Builder builder; if(Build.VERSION.SDK_INT>=Build.VERSION_CODES.O){ @@ -215,7 +221,21 @@ public class PushNotificationReceiver extends BroadcastReceiver{ builder.setSubText(accountName); } - int id = GlobalUserPreferences.keepOnlyLatestNotification ? NOTIFICATION_ID : notificationId++; + int id; + if(session.getLocalPreferences().keepOnlyLatestNotification){ + if(notificationIdsForAccounts.containsKey(accountID)){ + // we overwrite the existing notification + id=notificationIdsForAccounts.get(accountID); + }else{ + // there's no existing notification, so we increment + id=notificationId++; + // and store the notification id for this account + notificationIdsForAccounts.put(accountID, id); + } + }else{ + // we don't want to overwrite anything, therefore incrementing + id=notificationId++; + } if (notification != null){ switch (pn.notificationType){ diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/CacheController.java b/mastodon/src/main/java/org/joinmastodon/android/api/CacheController.java index 3cab9c71f..79cf1c680 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/api/CacheController.java +++ b/mastodon/src/main/java/org/joinmastodon/android/api/CacheController.java @@ -13,22 +13,20 @@ import org.joinmastodon.android.BuildConfig; import org.joinmastodon.android.MastodonApp; import org.joinmastodon.android.api.requests.notifications.GetNotifications; import org.joinmastodon.android.api.requests.timelines.GetHomeTimeline; -import org.joinmastodon.android.api.session.AccountSession; import org.joinmastodon.android.api.session.AccountSessionManager; import org.joinmastodon.android.model.CacheablePaginatedResponse; -import org.joinmastodon.android.model.Filter; +import org.joinmastodon.android.model.FilterContext; import org.joinmastodon.android.model.Instance; import org.joinmastodon.android.model.Notification; +import org.joinmastodon.android.model.PaginatedResponse; import org.joinmastodon.android.model.SearchResult; import org.joinmastodon.android.model.Status; -import org.joinmastodon.android.utils.StatusFilterPredicate; import java.io.IOException; import java.util.ArrayList; import java.util.EnumSet; import java.util.List; import java.util.function.Consumer; -import java.util.stream.Collectors; import me.grishka.appkit.api.Callback; import me.grishka.appkit.api.ErrorResponse; @@ -43,6 +41,8 @@ public class CacheController{ private final String accountID; private DatabaseHelper db; private final Runnable databaseCloseRunnable=this::closeDatabase; + private boolean loadingNotifications; + private final ArrayList>>> pendingNotificationsCallbacks=new ArrayList<>(); private static final int POST_FLAG_GAP_AFTER=1; @@ -58,7 +58,6 @@ public class CacheController{ cancelDelayedClose(); databaseThread.postRunnable(()->{ try{ - List filters=AccountSessionManager.getInstance().getAccount(accountID).wordFilters.stream().filter(f->f.context.contains(Filter.FilterContext.HOME)).collect(Collectors.toList()); if(!forceReload){ SQLiteDatabase db=getOrOpenDatabase(); try(Cursor cursor=db.query("home_timeline", new String[]{"json", "flags"}, maxID==null ? null : "`id` result=new ArrayList<>(); cursor.moveToFirst(); String newMaxID; - outer: do{ Status status=MastodonAPIController.gson.fromJson(cursor.getString(0), Status.class); status.postprocess(); int flags=cursor.getInt(1); status.hasGapAfter=((flags & POST_FLAG_GAP_AFTER)!=0); newMaxID=status.id; - if (!new StatusFilterPredicate(filters, Filter.FilterContext.HOME).test(status)) - continue outer; result.add(status); }while(cursor.moveToNext()); String _newMaxID=newMaxID; + AccountSessionManager.get(accountID).filterStatuses(result, FilterContext.HOME); uiHandler.post(()->callback.onSuccess(new CacheablePaginatedResponse<>(result, _newMaxID, true))); return; } @@ -85,11 +82,13 @@ public class CacheController{ Log.w(TAG, "getHomeTimeline: corrupted status object in database", x); } } - new GetHomeTimeline(maxID, null, count, null) + new GetHomeTimeline(maxID, null, count, null, AccountSessionManager.get(accountID).getLocalPreferences().timelineReplyVisibility) .setCallback(new Callback<>(){ @Override public void onSuccess(List result){ - callback.onSuccess(new CacheablePaginatedResponse<>(result.stream().filter(new StatusFilterPredicate(filters, Filter.FilterContext.HOME)).collect(Collectors.toList()), result.isEmpty() ? null : result.get(result.size()-1).id, false)); + ArrayList filtered=new ArrayList<>(result); + AccountSessionManager.get(accountID).filterStatuses(filtered, FilterContext.HOME); + callback.onSuccess(new CacheablePaginatedResponse<>(filtered, result.isEmpty() ? null : result.get(result.size()-1).id, false)); putHomeTimeline(result, maxID==null); } @@ -126,12 +125,39 @@ public class CacheController{ }); } - public void getNotifications(String maxID, int count, boolean onlyMentions, boolean onlyPosts, boolean forceReload, Callback>> callback){ + public void updateStatus(Status status) { + runOnDbThread((db)->{ + ContentValues statusUpdate=new ContentValues(1); + statusUpdate.put("json", MastodonAPIController.gson.toJson(status)); + db.update("home_timeline", statusUpdate, "id = ?", new String[] { status.id }); + }); + } + + public void updateNotification(Notification notification) { + runOnDbThread((db)->{ + ContentValues notificationUpdate=new ContentValues(1); + notificationUpdate.put("json", MastodonAPIController.gson.toJson(notification)); + String[] notificationArgs = new String[] { notification.id }; + db.update("notifications_all", notificationUpdate, "id = ?", notificationArgs); + db.update("notifications_mentions", notificationUpdate, "id = ?", notificationArgs); + db.update("notifications_posts", notificationUpdate, "id = ?", notificationArgs); + + ContentValues statusUpdate=new ContentValues(1); + statusUpdate.put("json", MastodonAPIController.gson.toJson(notification.status)); + db.update("home_timeline", statusUpdate, "id = ?", new String[] { notification.status.id }); + }); + } + + public void getNotifications(String maxID, int count, boolean onlyMentions, boolean onlyPosts, boolean forceReload, Callback>> callback){ cancelDelayedClose(); databaseThread.postRunnable(()->{ try{ - AccountSession accountSession=AccountSessionManager.getInstance().getAccount(accountID); - List filters=accountSession.wordFilters.stream().filter(f->f.context.contains(Filter.FilterContext.NOTIFICATIONS)).collect(Collectors.toList()); + if(!onlyMentions && !onlyPosts && loadingNotifications){ + synchronized(pendingNotificationsCallbacks){ + pendingNotificationsCallbacks.add(callback); + } + return; + } if(!forceReload){ SQLiteDatabase db=getOrOpenDatabase(); String table=onlyPosts ? "notifications_posts" : onlyMentions ? "notifications_mentions" : "notifications_all"; @@ -140,42 +166,56 @@ public class CacheController{ ArrayList result=new ArrayList<>(); cursor.moveToFirst(); String newMaxID; - outer: do{ Notification ntf=MastodonAPIController.gson.fromJson(cursor.getString(0), Notification.class); ntf.postprocess(); newMaxID=ntf.id; - if(ntf.status!=null){ - if (!new StatusFilterPredicate(filters, Filter.FilterContext.NOTIFICATIONS).test(ntf.status)) - continue outer; - } result.add(ntf); }while(cursor.moveToNext()); String _newMaxID=newMaxID; - uiHandler.post(()->callback.onSuccess(new CacheablePaginatedResponse<>(result, _newMaxID, true))); + AccountSessionManager.get(accountID).filterStatusContainingObjects(result, n->n.status, FilterContext.NOTIFICATIONS); + uiHandler.post(()->callback.onSuccess(new PaginatedResponse<>(result, _newMaxID))); return; } }catch(IOException x){ Log.w(TAG, "getNotifications: corrupted notification object in database", x); } } - Instance instance=AccountSessionManager.getInstance().getInstanceInfo(accountSession.domain); - new GetNotifications(maxID, count, onlyPosts ? EnumSet.of(Notification.Type.STATUS) : onlyMentions ? EnumSet.of(Notification.Type.MENTION): EnumSet.allOf(Notification.Type.class), instance.isAkkoma()) + if(!onlyMentions && !onlyPosts) + loadingNotifications=true; + boolean isAkkoma = AccountSessionManager.get(accountID).getInstance().map(Instance::isAkkoma).orElse(false); + new GetNotifications(maxID, count, onlyPosts ? EnumSet.of(Notification.Type.STATUS) : onlyMentions ? EnumSet.of(Notification.Type.MENTION): EnumSet.allOf(Notification.Type.class), isAkkoma) .setCallback(new Callback<>(){ @Override public void onSuccess(List result){ - callback.onSuccess(new CacheablePaginatedResponse<>(result.stream().filter(ntf->{ - if(ntf.status!=null){ - return new StatusFilterPredicate(filters, Filter.FilterContext.NOTIFICATIONS).test(ntf.status); - } - return true; - }).collect(Collectors.toList()), result.isEmpty() ? null : result.get(result.size()-1).id, false)); + ArrayList filtered=new ArrayList<>(result); + AccountSessionManager.get(accountID).filterStatusContainingObjects(filtered, n->n.status, FilterContext.NOTIFICATIONS); + PaginatedResponse> res=new PaginatedResponse<>(filtered, result.isEmpty() ? null : result.get(result.size()-1).id); + callback.onSuccess(res); putNotifications(result, onlyMentions, onlyPosts, maxID==null); + if(!onlyMentions){ + loadingNotifications=false; + synchronized(pendingNotificationsCallbacks){ + for(Callback>> cb:pendingNotificationsCallbacks){ + cb.onSuccess(res); + } + pendingNotificationsCallbacks.clear(); + } + } } @Override public void onError(ErrorResponse error){ callback.onError(error); + if(!onlyMentions){ + loadingNotifications=false; + synchronized(pendingNotificationsCallbacks){ + for(Callback>> cb:pendingNotificationsCallbacks){ + cb.onError(error); + } + pendingNotificationsCallbacks.clear(); + } + } } }) .exec(accountID); @@ -327,7 +367,7 @@ public class CacheController{ createRecentSearchesTable(db); } if(oldVersion<3){ - // MEGALODON-SPECIFIC + // MEGALODON createPostsNotificationsTable(db); } if(oldVersion<4){ diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/MastodonAPIController.java b/mastodon/src/main/java/org/joinmastodon/android/api/MastodonAPIController.java index b941a485f..ae398cdbf 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/api/MastodonAPIController.java +++ b/mastodon/src/main/java/org/joinmastodon/android/api/MastodonAPIController.java @@ -117,6 +117,9 @@ public class MastodonAPIController{ synchronized(req){ req.okhttpCall=call; } + if(req.timeout>0){ + call.timeout().timeout(req.timeout, TimeUnit.MILLISECONDS); + } if(BuildConfig.DEBUG) Log.d(TAG, "["+(session==null ? "no-auth" : session.getID())+"] Sending request: "+hreq); @@ -153,13 +156,17 @@ public class MastodonAPIController{ Log.d(TAG, "["+(session==null ? "no-auth" : session.getID())+"] response body: "+respJson); if(req.respTypeToken!=null) respObj=gson.fromJson(respJson, req.respTypeToken.getType()); - else + else if(req.respClass!=null) respObj=gson.fromJson(respJson, req.respClass); + else + respObj=null; }else{ if(req.respTypeToken!=null) respObj=gson.fromJson(reader, req.respTypeToken.getType()); - else + else if(req.respClass!=null) respObj=gson.fromJson(reader, req.respClass); + else + respObj=null; } }catch(JsonIOException|JsonSyntaxException x){ if (req.context != null && response.body().contentType().subtype().equals("html")) { diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/MastodonAPIRequest.java b/mastodon/src/main/java/org/joinmastodon/android/api/MastodonAPIRequest.java index b6d624588..ffd9d0284 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/api/MastodonAPIRequest.java +++ b/mastodon/src/main/java/org/joinmastodon/android/api/MastodonAPIRequest.java @@ -49,6 +49,7 @@ public abstract class MastodonAPIRequest extends APIRequest{ Token token; boolean canceled, isRemote; Map headers; + long timeout; private ProgressDialog progressDialog; protected boolean removeUnsupportedItems; @Nullable Context context; @@ -117,7 +118,7 @@ public abstract class MastodonAPIRequest extends APIRequest{ .findAny()) .map(AccountSession::getID) .map(this::exec) - .orElse(this.execNoAuth(domain)); + .orElseGet(() -> this.execNoAuth(domain)); } public MastodonAPIRequest wrapProgress(Activity activity, @StringRes int message, boolean cancelable){ @@ -152,6 +153,10 @@ public abstract class MastodonAPIRequest extends APIRequest{ headers.put(key, value); } + protected void setTimeout(long timeout){ + this.timeout=timeout; + } + protected String getPathPrefix(){ return "/api/v1"; } diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/PushSubscriptionManager.java b/mastodon/src/main/java/org/joinmastodon/android/api/PushSubscriptionManager.java index 84dd040d2..281307a00 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/api/PushSubscriptionManager.java +++ b/mastodon/src/main/java/org/joinmastodon/android/api/PushSubscriptionManager.java @@ -87,7 +87,6 @@ public class PushSubscriptionManager{ private String accountID; private PrivateKey privateKey; private PublicKey publicKey; - private PublicKey serverKey; private byte[] authKey; public PushSubscriptionManager(String accountID){ @@ -162,10 +161,6 @@ public class PushSubscriptionManager{ @Override public void onSuccess(PushSubscription result){ MastodonAPIController.runInBackground(()->{ - result.serverKey=result.serverKey.replace('/','_'); - result.serverKey=result.serverKey.replace('+','-'); - serverKey=deserializeRawPublicKey(Base64.decode(result.serverKey, Base64.URL_SAFE)); - AccountSession session=AccountSessionManager.getInstance().tryGetAccount(accountID); if(session==null) return; diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/ResultlessMastodonAPIRequest.java b/mastodon/src/main/java/org/joinmastodon/android/api/ResultlessMastodonAPIRequest.java new file mode 100644 index 000000000..26900f063 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/api/ResultlessMastodonAPIRequest.java @@ -0,0 +1,9 @@ +package org.joinmastodon.android.api; + +import com.google.gson.reflect.TypeToken; + +public abstract class ResultlessMastodonAPIRequest extends MastodonAPIRequest{ + public ResultlessMastodonAPIRequest(HttpMethod method, String path){ + super(method, path, (Class)null); + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/requests/accounts/GetAccountFeaturedHashtags.java b/mastodon/src/main/java/org/joinmastodon/android/api/requests/accounts/GetAccountFeaturedHashtags.java new file mode 100644 index 000000000..cac75472b --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/api/requests/accounts/GetAccountFeaturedHashtags.java @@ -0,0 +1,14 @@ +package org.joinmastodon.android.api.requests.accounts; + +import com.google.gson.reflect.TypeToken; + +import org.joinmastodon.android.api.MastodonAPIRequest; +import org.joinmastodon.android.model.Hashtag; + +import java.util.List; + +public class GetAccountFeaturedHashtags extends MastodonAPIRequest>{ + public GetAccountFeaturedHashtags(String id){ + super(HttpMethod.GET, "/accounts/"+id+"/featured_tags", new TypeToken<>(){}); + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/requests/accounts/GetAccountStatuses.java b/mastodon/src/main/java/org/joinmastodon/android/api/requests/accounts/GetAccountStatuses.java index d1e8553ff..0b8271d59 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/api/requests/accounts/GetAccountStatuses.java +++ b/mastodon/src/main/java/org/joinmastodon/android/api/requests/accounts/GetAccountStatuses.java @@ -21,22 +21,22 @@ public class GetAccountStatuses extends MastodonAPIRequest>{ switch(filter){ case DEFAULT -> addQueryParameter("exclude_replies", "true"); case INCLUDE_REPLIES -> {} - case PINNED -> addQueryParameter("pinned", "true"); case MEDIA -> addQueryParameter("only_media", "true"); case NO_REBLOGS -> { addQueryParameter("exclude_replies", "true"); addQueryParameter("exclude_reblogs", "true"); } case OWN_POSTS_AND_REPLIES -> addQueryParameter("exclude_reblogs", "true"); + case PINNED -> addQueryParameter("pinned", "true"); } } public enum Filter{ DEFAULT, INCLUDE_REPLIES, - PINNED, MEDIA, NO_REBLOGS, - OWN_POSTS_AND_REPLIES + OWN_POSTS_AND_REPLIES, + PINNED } } diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/requests/accounts/RegisterAccount.java b/mastodon/src/main/java/org/joinmastodon/android/api/requests/accounts/RegisterAccount.java index 7656d6d4c..622145237 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/api/requests/accounts/RegisterAccount.java +++ b/mastodon/src/main/java/org/joinmastodon/android/api/requests/accounts/RegisterAccount.java @@ -4,21 +4,22 @@ import org.joinmastodon.android.api.MastodonAPIRequest; import org.joinmastodon.android.model.Token; public class RegisterAccount extends MastodonAPIRequest{ - public RegisterAccount(String username, String email, String password, String locale, String reason){ + public RegisterAccount(String username, String email, String password, String locale, String reason, String timezone){ super(HttpMethod.POST, "/accounts", Token.class); - setRequestBody(new Body(username, email, password, locale, reason)); + setRequestBody(new Body(username, email, password, locale, reason, timezone)); } private static class Body{ - public String username, email, password, locale, reason; + public String username, email, password, locale, reason, timeZone; public boolean agreement=true; - public Body(String username, String email, String password, String locale, String reason){ + public Body(String username, String email, String password, String locale, String reason, String timeZone){ this.username=username; this.email=email; this.password=password; this.locale=locale; this.reason=reason; + this.timeZone=timeZone; } } } diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/requests/accounts/UpdateAccountCredentialsPreferences.java b/mastodon/src/main/java/org/joinmastodon/android/api/requests/accounts/UpdateAccountCredentialsPreferences.java new file mode 100644 index 000000000..686b64e3f --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/api/requests/accounts/UpdateAccountCredentialsPreferences.java @@ -0,0 +1,34 @@ +package org.joinmastodon.android.api.requests.accounts; + +import org.joinmastodon.android.api.MastodonAPIRequest; +import org.joinmastodon.android.model.Account; +import org.joinmastodon.android.model.Preferences; +import org.joinmastodon.android.model.StatusPrivacy; + +public class UpdateAccountCredentialsPreferences extends MastodonAPIRequest{ + public UpdateAccountCredentialsPreferences(Preferences preferences, Boolean locked, Boolean discoverable){ + super(HttpMethod.PATCH, "/accounts/update_credentials", Account.class); + setRequestBody(new Request(locked, discoverable, new RequestSource(preferences.postingDefaultVisibility, preferences.postingDefaultLanguage))); + } + + private static class Request{ + public Boolean locked, discoverable; + public RequestSource source; + + public Request(Boolean locked, Boolean discoverable, RequestSource source){ + this.locked=locked; + this.discoverable=discoverable; + this.source=source; + } + } + + private static class RequestSource{ + public StatusPrivacy privacy; + public String language; + + public RequestSource(StatusPrivacy privacy, String language){ + this.privacy=privacy; + this.language=language; + } + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/requests/catalog/GetCatalogDefaultInstances.java b/mastodon/src/main/java/org/joinmastodon/android/api/requests/catalog/GetCatalogDefaultInstances.java new file mode 100644 index 000000000..238ce2d84 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/api/requests/catalog/GetCatalogDefaultInstances.java @@ -0,0 +1,22 @@ +package org.joinmastodon.android.api.requests.catalog; + +import android.net.Uri; + +import com.google.gson.reflect.TypeToken; + +import org.joinmastodon.android.api.MastodonAPIRequest; +import org.joinmastodon.android.model.catalog.CatalogDefaultInstance; + +import java.util.List; + +public class GetCatalogDefaultInstances extends MastodonAPIRequest>{ + public GetCatalogDefaultInstances(){ + super(HttpMethod.GET, null, new TypeToken<>(){}); + setTimeout(500); + } + + @Override + public Uri getURL(){ + return Uri.parse("https://api.joinmastodon.org/default-servers"); + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/requests/filters/CreateFilter.java b/mastodon/src/main/java/org/joinmastodon/android/api/requests/filters/CreateFilter.java new file mode 100644 index 000000000..ac0477a1d --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/api/requests/filters/CreateFilter.java @@ -0,0 +1,23 @@ +package org.joinmastodon.android.api.requests.filters; + +import org.joinmastodon.android.api.MastodonAPIRequest; +import org.joinmastodon.android.model.Filter; +import org.joinmastodon.android.model.FilterAction; +import org.joinmastodon.android.model.FilterContext; +import org.joinmastodon.android.model.FilterKeyword; + +import java.util.EnumSet; +import java.util.List; +import java.util.stream.Collectors; + +public class CreateFilter extends MastodonAPIRequest{ + public CreateFilter(String title, EnumSet context, FilterAction action, int expiresIn, List words){ + super(HttpMethod.POST, "/filters", Filter.class); + setRequestBody(new FilterRequest(title, context, action, expiresIn==0 ? null : expiresIn, words.stream().map(w->new KeywordAttribute(null, null, w.keyword, w.wholeWord)).collect(Collectors.toList()))); + } + + @Override + protected String getPathPrefix(){ + return "/api/v2"; + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/requests/filters/DeleteFilter.java b/mastodon/src/main/java/org/joinmastodon/android/api/requests/filters/DeleteFilter.java new file mode 100644 index 000000000..6c5400a80 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/api/requests/filters/DeleteFilter.java @@ -0,0 +1,14 @@ +package org.joinmastodon.android.api.requests.filters; + +import org.joinmastodon.android.api.ResultlessMastodonAPIRequest; + +public class DeleteFilter extends ResultlessMastodonAPIRequest{ + public DeleteFilter(String id){ + super(HttpMethod.DELETE, "/filters/"+id); + } + + @Override + protected String getPathPrefix(){ + return "/api/v2"; + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/requests/filters/FilterRequest.java b/mastodon/src/main/java/org/joinmastodon/android/api/requests/filters/FilterRequest.java new file mode 100644 index 000000000..ff61d536f --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/api/requests/filters/FilterRequest.java @@ -0,0 +1,23 @@ +package org.joinmastodon.android.api.requests.filters; + +import org.joinmastodon.android.model.FilterAction; +import org.joinmastodon.android.model.FilterContext; + +import java.util.EnumSet; +import java.util.List; + +class FilterRequest{ + public String title; + public EnumSet context; + public FilterAction filterAction; + public Integer expiresIn; + public List keywordsAttributes; + + public FilterRequest(String title, EnumSet context, FilterAction filterAction, Integer expiresIn, List keywordsAttributes){ + this.title=title; + this.context=context; + this.filterAction=filterAction; + this.expiresIn=expiresIn; + this.keywordsAttributes=keywordsAttributes; + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/requests/accounts/GetWordFilters.java b/mastodon/src/main/java/org/joinmastodon/android/api/requests/filters/GetFilters.java similarity index 52% rename from mastodon/src/main/java/org/joinmastodon/android/api/requests/accounts/GetWordFilters.java rename to mastodon/src/main/java/org/joinmastodon/android/api/requests/filters/GetFilters.java index 781035959..904d42f9e 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/api/requests/accounts/GetWordFilters.java +++ b/mastodon/src/main/java/org/joinmastodon/android/api/requests/filters/GetFilters.java @@ -1,4 +1,4 @@ -package org.joinmastodon.android.api.requests.accounts; +package org.joinmastodon.android.api.requests.filters; import com.google.gson.reflect.TypeToken; @@ -7,8 +7,13 @@ import org.joinmastodon.android.model.Filter; import java.util.List; -public class GetWordFilters extends MastodonAPIRequest>{ - public GetWordFilters(){ +public class GetFilters extends MastodonAPIRequest>{ + public GetFilters(){ super(HttpMethod.GET, "/filters", new TypeToken<>(){}); } + + @Override + protected String getPathPrefix(){ + return "/api/v2"; + } } diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/requests/filters/GetLegacyFilters.java b/mastodon/src/main/java/org/joinmastodon/android/api/requests/filters/GetLegacyFilters.java new file mode 100644 index 000000000..eeefe13a7 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/api/requests/filters/GetLegacyFilters.java @@ -0,0 +1,14 @@ +package org.joinmastodon.android.api.requests.filters; + +import com.google.gson.reflect.TypeToken; + +import org.joinmastodon.android.api.MastodonAPIRequest; +import org.joinmastodon.android.model.LegacyFilter; + +import java.util.List; + +public class GetLegacyFilters extends MastodonAPIRequest>{ + public GetLegacyFilters(){ + super(HttpMethod.GET, "/filters", new TypeToken<>(){}); + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/requests/filters/KeywordAttribute.java b/mastodon/src/main/java/org/joinmastodon/android/api/requests/filters/KeywordAttribute.java new file mode 100644 index 000000000..d35a0f0fa --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/api/requests/filters/KeywordAttribute.java @@ -0,0 +1,18 @@ +package org.joinmastodon.android.api.requests.filters; + +import com.google.gson.annotations.SerializedName; + +class KeywordAttribute{ + public String id; + @SerializedName("_destroy") + public Boolean delete; + public String keyword; + public Boolean wholeWord; + + public KeywordAttribute(String id, Boolean delete, String keyword, Boolean wholeWord){ + this.id=id; + this.delete=delete; + this.keyword=keyword; + this.wholeWord=wholeWord; + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/requests/filters/UpdateFilter.java b/mastodon/src/main/java/org/joinmastodon/android/api/requests/filters/UpdateFilter.java new file mode 100644 index 000000000..2c296540f --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/api/requests/filters/UpdateFilter.java @@ -0,0 +1,30 @@ +package org.joinmastodon.android.api.requests.filters; + +import org.joinmastodon.android.api.MastodonAPIRequest; +import org.joinmastodon.android.model.Filter; +import org.joinmastodon.android.model.FilterAction; +import org.joinmastodon.android.model.FilterContext; +import org.joinmastodon.android.model.FilterKeyword; + +import java.util.EnumSet; +import java.util.List; +import java.util.function.Function; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +public class UpdateFilter extends MastodonAPIRequest{ + public UpdateFilter(String id, String title, EnumSet context, FilterAction action, int expiresIn, List words, List deletedWords){ + super(HttpMethod.PUT, "/filters/"+id, Filter.class); + + List attrs=Stream.of( + words.stream().map(w->new KeywordAttribute(w.id, null, w.keyword, w.wholeWord)), + deletedWords.stream().map(wid->new KeywordAttribute(wid, true, null, null)) + ).flatMap(Function.identity()).collect(Collectors.toList()); + setRequestBody(new FilterRequest(title, context, action, expiresIn==0 ? null : expiresIn, attrs)); + } + + @Override + protected String getPathPrefix(){ + return "/api/v2"; + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/requests/instance/GetInstanceExtendedDescription.java b/mastodon/src/main/java/org/joinmastodon/android/api/requests/instance/GetInstanceExtendedDescription.java new file mode 100644 index 000000000..3d0487e89 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/api/requests/instance/GetInstanceExtendedDescription.java @@ -0,0 +1,16 @@ +package org.joinmastodon.android.api.requests.instance; + +import org.joinmastodon.android.api.MastodonAPIRequest; + +import java.time.Instant; + +public class GetInstanceExtendedDescription extends MastodonAPIRequest{ + public GetInstanceExtendedDescription(){ + super(HttpMethod.GET, "/instance/extended_description", Response.class); + } + + public static class Response{ + public Instant updatedAt; + public String content; + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/requests/markers/GetMarkers.java b/mastodon/src/main/java/org/joinmastodon/android/api/requests/markers/GetMarkers.java index b7dd6536b..644665bae 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/api/requests/markers/GetMarkers.java +++ b/mastodon/src/main/java/org/joinmastodon/android/api/requests/markers/GetMarkers.java @@ -1,17 +1,12 @@ package org.joinmastodon.android.api.requests.markers; -import org.joinmastodon.android.api.ApiUtils; import org.joinmastodon.android.api.MastodonAPIRequest; -import org.joinmastodon.android.model.Marker; -import org.joinmastodon.android.model.Markers; +import org.joinmastodon.android.model.TimelineMarkers; -import java.util.EnumSet; - -public class GetMarkers extends MastodonAPIRequest { - public GetMarkers(EnumSet timelines) { - super(HttpMethod.GET, "/markers", Markers.class); - for (String type : ApiUtils.enumSetToStrings(timelines, Marker.Type.class)){ - addQueryParameter("timeline[]", type); - } +public class GetMarkers extends MastodonAPIRequest{ + public GetMarkers(){ + super(HttpMethod.GET, "/markers", TimelineMarkers.class); + addQueryParameter("timeline[]", "home"); + addQueryParameter("timeline[]", "notifications"); } } diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/requests/markers/SaveMarkers.java b/mastodon/src/main/java/org/joinmastodon/android/api/requests/markers/SaveMarkers.java index f432504bf..eeda81b44 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/api/requests/markers/SaveMarkers.java +++ b/mastodon/src/main/java/org/joinmastodon/android/api/requests/markers/SaveMarkers.java @@ -2,11 +2,11 @@ package org.joinmastodon.android.api.requests.markers; import org.joinmastodon.android.api.MastodonAPIRequest; import org.joinmastodon.android.api.gson.JsonObjectBuilder; -import org.joinmastodon.android.model.Marker; +import org.joinmastodon.android.model.TimelineMarkers; -public class SaveMarkers extends MastodonAPIRequest{ +public class SaveMarkers extends MastodonAPIRequest{ public SaveMarkers(String lastSeenHomePostID, String lastSeenNotificationID){ - super(HttpMethod.POST, "/markers", Response.class); + super(HttpMethod.POST, "/markers", TimelineMarkers.class); JsonObjectBuilder builder=new JsonObjectBuilder(); if(lastSeenHomePostID!=null) builder.add("home", new JsonObjectBuilder().add("last_read_id", lastSeenHomePostID)); @@ -14,8 +14,4 @@ public class SaveMarkers extends MastodonAPIRequest{ builder.add("notifications", new JsonObjectBuilder().add("last_read_id", lastSeenNotificationID)); setRequestBody(builder.build()); } - - public static class Response{ - public Marker home, notifications; - } } diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/requests/search/GetSearchResults.java b/mastodon/src/main/java/org/joinmastodon/android/api/requests/search/GetSearchResults.java index a35745988..0407bb702 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/api/requests/search/GetSearchResults.java +++ b/mastodon/src/main/java/org/joinmastodon/android/api/requests/search/GetSearchResults.java @@ -13,6 +13,11 @@ public class GetSearchResults extends MastodonAPIRequest{ addQueryParameter("resolve", "true"); } + public GetSearchResults limit(int limit){ + addQueryParameter("limit", String.valueOf(limit)); + return this; + } + @Override protected String getPathPrefix(){ return "/api/v2"; diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/requests/timelines/GetBubbleTimeline.java b/mastodon/src/main/java/org/joinmastodon/android/api/requests/timelines/GetBubbleTimeline.java index 9b54d1895..fbb19a0f0 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/api/requests/timelines/GetBubbleTimeline.java +++ b/mastodon/src/main/java/org/joinmastodon/android/api/requests/timelines/GetBubbleTimeline.java @@ -4,20 +4,19 @@ import android.text.TextUtils; import com.google.gson.reflect.TypeToken; -import org.joinmastodon.android.GlobalUserPreferences; import org.joinmastodon.android.api.MastodonAPIRequest; import org.joinmastodon.android.model.Status; import java.util.List; public class GetBubbleTimeline extends MastodonAPIRequest> { - public GetBubbleTimeline(String maxID, int limit) { + public GetBubbleTimeline(String maxID, int limit, String replyVisibility) { super(HttpMethod.GET, "/timelines/bubble", new TypeToken<>(){}); if(!TextUtils.isEmpty(maxID)) addQueryParameter("max_id", maxID); if(limit>0) addQueryParameter("limit", limit+""); - if(GlobalUserPreferences.replyVisibility != null) - addQueryParameter("reply_visibility", GlobalUserPreferences.replyVisibility); + if(replyVisibility != null) + addQueryParameter("reply_visibility", replyVisibility); } } diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/requests/timelines/GetHashtagTimeline.java b/mastodon/src/main/java/org/joinmastodon/android/api/requests/timelines/GetHashtagTimeline.java index 6cf31542d..a47c47d61 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/api/requests/timelines/GetHashtagTimeline.java +++ b/mastodon/src/main/java/org/joinmastodon/android/api/requests/timelines/GetHashtagTimeline.java @@ -2,16 +2,13 @@ package org.joinmastodon.android.api.requests.timelines; import com.google.gson.reflect.TypeToken; -import android.text.TextUtils; - -import org.joinmastodon.android.GlobalUserPreferences; import org.joinmastodon.android.api.MastodonAPIRequest; import org.joinmastodon.android.model.Status; import java.util.List; public class GetHashtagTimeline extends MastodonAPIRequest>{ - public GetHashtagTimeline(String hashtag, String maxID, String minID, int limit, List containsAny, List containsAll, List containsNone, boolean localOnly){ + public GetHashtagTimeline(String hashtag, String maxID, String minID, int limit, List containsAny, List containsAll, List containsNone, boolean localOnly, String replyVisibility){ super(HttpMethod.GET, "/timelines/tag/"+hashtag, new TypeToken<>(){}); if (localOnly) addQueryParameter("local", "true"); @@ -30,17 +27,7 @@ public class GetHashtagTimeline extends MastodonAPIRequest>{ if(containsNone!=null) for (String tag : containsNone) addQueryParameter("none[]", tag); - } - - public GetHashtagTimeline(String hashtag, String maxID, String minID, int limit){ - super(HttpMethod.GET, "/timelines/tag/"+hashtag, new TypeToken<>(){}); - if(maxID!=null) - addQueryParameter("max_id", maxID); - if(minID!=null) - addQueryParameter("min_id", minID); - if(limit>0) - addQueryParameter("limit", ""+limit); - if(GlobalUserPreferences.replyVisibility != null) - addQueryParameter("reply_visibility", GlobalUserPreferences.replyVisibility); + if(replyVisibility != null) + addQueryParameter("reply_visibility", replyVisibility); } } diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/requests/timelines/GetHomeTimeline.java b/mastodon/src/main/java/org/joinmastodon/android/api/requests/timelines/GetHomeTimeline.java index 3792c5a66..1a605cab2 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/api/requests/timelines/GetHomeTimeline.java +++ b/mastodon/src/main/java/org/joinmastodon/android/api/requests/timelines/GetHomeTimeline.java @@ -2,14 +2,13 @@ package org.joinmastodon.android.api.requests.timelines; import com.google.gson.reflect.TypeToken; -import org.joinmastodon.android.GlobalUserPreferences; import org.joinmastodon.android.api.MastodonAPIRequest; import org.joinmastodon.android.model.Status; import java.util.List; public class GetHomeTimeline extends MastodonAPIRequest>{ - public GetHomeTimeline(String maxID, String minID, int limit, String sinceID){ + public GetHomeTimeline(String maxID, String minID, int limit, String sinceID, String replyVisibility){ super(HttpMethod.GET, "/timelines/home", new TypeToken<>(){}); if(maxID!=null) addQueryParameter("max_id", maxID); @@ -19,7 +18,7 @@ public class GetHomeTimeline extends MastodonAPIRequest>{ addQueryParameter("since_id", sinceID); if(limit>0) addQueryParameter("limit", ""+limit); - if(GlobalUserPreferences.replyVisibility != null) - addQueryParameter("reply_visibility", GlobalUserPreferences.replyVisibility); + if(replyVisibility != null) + addQueryParameter("reply_visibility", replyVisibility); } } diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/requests/timelines/GetListTimeline.java b/mastodon/src/main/java/org/joinmastodon/android/api/requests/timelines/GetListTimeline.java index 82d537971..29dfb67ac 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/api/requests/timelines/GetListTimeline.java +++ b/mastodon/src/main/java/org/joinmastodon/android/api/requests/timelines/GetListTimeline.java @@ -2,14 +2,13 @@ package org.joinmastodon.android.api.requests.timelines; import com.google.gson.reflect.TypeToken; -import org.joinmastodon.android.GlobalUserPreferences; import org.joinmastodon.android.api.MastodonAPIRequest; import org.joinmastodon.android.model.Status; import java.util.List; public class GetListTimeline extends MastodonAPIRequest> { - public GetListTimeline(String listID, String maxID, String minID, int limit, String sinceID) { + public GetListTimeline(String listID, String maxID, String minID, int limit, String sinceID, String replyVisibility) { super(HttpMethod.GET, "/timelines/list/"+listID, new TypeToken<>(){}); if(maxID!=null) addQueryParameter("max_id", maxID); @@ -19,7 +18,7 @@ public class GetListTimeline extends MastodonAPIRequest> { addQueryParameter("limit", ""+limit); if(sinceID!=null) addQueryParameter("since_id", sinceID); - if(GlobalUserPreferences.replyVisibility != null) - addQueryParameter("reply_visibility", GlobalUserPreferences.replyVisibility); + if(replyVisibility != null) + addQueryParameter("reply_visibility", replyVisibility); } } diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/requests/timelines/GetPublicTimeline.java b/mastodon/src/main/java/org/joinmastodon/android/api/requests/timelines/GetPublicTimeline.java index 7ec562704..328bf869a 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/api/requests/timelines/GetPublicTimeline.java +++ b/mastodon/src/main/java/org/joinmastodon/android/api/requests/timelines/GetPublicTimeline.java @@ -4,14 +4,13 @@ import android.text.TextUtils; import com.google.gson.reflect.TypeToken; -import org.joinmastodon.android.GlobalUserPreferences; import org.joinmastodon.android.api.MastodonAPIRequest; import org.joinmastodon.android.model.Status; import java.util.List; public class GetPublicTimeline extends MastodonAPIRequest>{ - public GetPublicTimeline(boolean local, boolean remote, String maxID, int limit){ + public GetPublicTimeline(boolean local, boolean remote, String maxID, int limit, String replyVisibility){ super(HttpMethod.GET, "/timelines/public", new TypeToken<>(){}); if(local) addQueryParameter("local", "true"); @@ -21,7 +20,7 @@ public class GetPublicTimeline extends MastodonAPIRequest>{ addQueryParameter("max_id", maxID); if(limit>0) addQueryParameter("limit", limit+""); - if(GlobalUserPreferences.replyVisibility != null) - addQueryParameter("reply_visibility", GlobalUserPreferences.replyVisibility); + if(replyVisibility != null) + addQueryParameter("reply_visibility", replyVisibility); } } diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/session/AccountLocalPreferences.java b/mastodon/src/main/java/org/joinmastodon/android/api/session/AccountLocalPreferences.java new file mode 100644 index 000000000..7da87e5a8 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/api/session/AccountLocalPreferences.java @@ -0,0 +1,98 @@ +package org.joinmastodon.android.api.session; + +import static org.joinmastodon.android.GlobalUserPreferences.fromJson; +import static org.joinmastodon.android.GlobalUserPreferences.enumValue; +import static org.joinmastodon.android.api.MastodonAPIController.gson; + +import android.content.SharedPreferences; + +import com.google.gson.reflect.TypeToken; + +import org.joinmastodon.android.model.ContentType; +import org.joinmastodon.android.model.TimelineDefinition; + +import java.lang.reflect.Type; +import java.util.ArrayList; +import java.util.List; + +public class AccountLocalPreferences{ + private final SharedPreferences prefs; + + public boolean showInteractionCounts; + public boolean customEmojiInNames; + public boolean revealCWs; + public boolean hideSensitiveMedia; + public boolean serverSideFiltersSupported; + + // MEGALODON + public boolean showReplies; + public boolean showBoosts; + public ArrayList recentLanguages; + public boolean bottomEncoding; + public ContentType defaultContentType; + public boolean contentTypesEnabled; + public ArrayList timelines; + public boolean localOnlySupported; + public boolean glitchInstance; + public String publishButtonText; + public String timelineReplyVisibility; // akkoma-only + public boolean keepOnlyLatestNotification; + + private final static Type recentLanguagesType = new TypeToken>() {}.getType(); + private final static Type timelinesType = new TypeToken>() {}.getType(); + + public AccountLocalPreferences(SharedPreferences prefs, AccountSession session){ + this.prefs=prefs; + showInteractionCounts=prefs.getBoolean("interactionCounts", false); + customEmojiInNames=prefs.getBoolean("emojiInNames", true); + revealCWs=prefs.getBoolean("revealCWs", false); + hideSensitiveMedia=prefs.getBoolean("hideSensitive", true); + serverSideFiltersSupported=prefs.getBoolean("serverSideFilters", false); + + // MEGALODON + showReplies=prefs.getBoolean("showReplies", true); + showBoosts=prefs.getBoolean("showBoosts", true); + recentLanguages=fromJson(prefs.getString("recentLanguages", null), recentLanguagesType, new ArrayList<>()); + bottomEncoding=prefs.getBoolean("bottomEncoding", false); + defaultContentType=enumValue(ContentType.class, prefs.getString("defaultContentType", ContentType.PLAIN.name())); + contentTypesEnabled=prefs.getBoolean("contentTypesEnabled", true); + timelines=fromJson(prefs.getString("timelines", null), timelinesType, TimelineDefinition.getDefaultTimelines(session.getID())); + localOnlySupported=prefs.getBoolean("localOnlySupported", false); + glitchInstance=prefs.getBoolean("glitchInstance", false); + publishButtonText=prefs.getString("publishButtonText", null); + timelineReplyVisibility=prefs.getString("timelineReplyVisibility", null); + keepOnlyLatestNotification=prefs.getBoolean("keepOnlyLatestNotification", false); + } + + public long getNotificationsPauseEndTime(){ + return prefs.getLong("notificationsPauseTime", 0L); + } + + public void setNotificationsPauseEndTime(long time){ + prefs.edit().putLong("notificationsPauseTime", time).apply(); + } + + public void save(){ + prefs.edit() + .putBoolean("interactionCounts", showInteractionCounts) + .putBoolean("emojiInNames", customEmojiInNames) + .putBoolean("revealCWs", revealCWs) + .putBoolean("hideSensitive", hideSensitiveMedia) + .putBoolean("serverSideFilters", serverSideFiltersSupported) + + // MEGALODON + .putBoolean("showReplies", showReplies) + .putBoolean("showBoosts", showBoosts) + .putString("recentLanguages", gson.toJson(recentLanguages)) + .putBoolean("bottomEncoding", bottomEncoding) + .putString("defaultContentType", defaultContentType==null ? null : defaultContentType.name()) + .putBoolean("contentTypesEnabled", contentTypesEnabled) + .putString("timelines", gson.toJson(timelines)) + .putBoolean("localOnlySupported", localOnlySupported) + .putBoolean("glitchInstance", glitchInstance) + .putString("publishButtonText", publishButtonText) + .putString("timelineReplyVisibility", timelineReplyVisibility) + .putBoolean("keepOnlyLatestNotification", keepOnlyLatestNotification) + .apply(); + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/session/AccountSession.java b/mastodon/src/main/java/org/joinmastodon/android/api/session/AccountSession.java index 30acb30d6..fde8cc566 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/api/session/AccountSession.java +++ b/mastodon/src/main/java/org/joinmastodon/android/api/session/AccountSession.java @@ -1,25 +1,53 @@ package org.joinmastodon.android.api.session; +import android.app.Activity; +import android.content.Context; +import android.content.SharedPreferences; import android.net.Uri; +import android.text.TextUtils; +import android.util.Log; +import org.joinmastodon.android.E; +import org.joinmastodon.android.MastodonApp; +import org.joinmastodon.android.R; import org.joinmastodon.android.api.CacheController; import org.joinmastodon.android.api.MastodonAPIController; import org.joinmastodon.android.api.PushSubscriptionManager; import org.joinmastodon.android.api.StatusInteractionController; +import org.joinmastodon.android.api.requests.accounts.GetPreferences; +import org.joinmastodon.android.api.requests.accounts.UpdateAccountCredentialsPreferences; +import org.joinmastodon.android.api.requests.markers.GetMarkers; +import org.joinmastodon.android.api.requests.markers.SaveMarkers; +import org.joinmastodon.android.api.requests.oauth.RevokeOauthToken; +import org.joinmastodon.android.events.NotificationsMarkerUpdatedEvent; import org.joinmastodon.android.model.Account; import org.joinmastodon.android.model.Application; -import org.joinmastodon.android.model.Filter; +import org.joinmastodon.android.model.FilterAction; +import org.joinmastodon.android.model.FilterContext; +import org.joinmastodon.android.model.FilterResult; import org.joinmastodon.android.model.Instance; -import org.joinmastodon.android.model.Markers; +import org.joinmastodon.android.model.LegacyFilter; import org.joinmastodon.android.model.Preferences; import org.joinmastodon.android.model.PushSubscription; +import org.joinmastodon.android.model.Status; +import org.joinmastodon.android.model.TimelineMarkers; import org.joinmastodon.android.model.Token; +import org.joinmastodon.android.utils.ObjectIdComparator; import java.util.ArrayList; import java.util.List; +import java.util.Objects; import java.util.Optional; +import java.util.function.Consumer; +import java.util.function.Function; +import java.util.function.Predicate; + +import me.grishka.appkit.api.Callback; +import me.grishka.appkit.api.ErrorResponse; public class AccountSession{ + private static final String TAG="AccountSession"; + public Token token; public Account self; public String domain; @@ -32,15 +60,17 @@ public class AccountSession{ public PushSubscription pushSubscription; public boolean needUpdatePushSettings; public long filtersLastUpdated; - public List wordFilters=new ArrayList<>(); + public List wordFilters=new ArrayList<>(); public String pushAccountID; - public Preferences preferences; public AccountActivationInfo activationInfo; - public Markers markers; + public Preferences preferences; private transient MastodonAPIController apiController; private transient StatusInteractionController statusInteractionController, remoteStatusInteractionController; private transient CacheController cacheController; private transient PushSubscriptionManager pushSubscriptionManager; + private transient SharedPreferences prefs; + private transient boolean preferencesNeedSaving; + private transient AccountLocalPreferences localPreferences; AccountSession(Token token, Account self, Application app, String domain, boolean activated, AccountActivationInfo activationInfo){ this.token=token; @@ -58,10 +88,6 @@ public class AccountSession{ return domain+"_"+self.id; } - public String getFullUsername() { - return "@"+self.username+"@"+domain; - } - public MastodonAPIController getApiController(){ if(apiController==null) apiController=new MastodonAPIController(this); @@ -92,6 +118,188 @@ public class AccountSession{ return pushSubscriptionManager; } + public String getFullUsername(){ + return '@'+self.username+'@'+domain; + } + + public void preferencesFromAccountSource(Account account) { + if (account != null && account.source != null && preferences != null) { + if (account.source.privacy != null) + preferences.postingDefaultVisibility = account.source.privacy; + if (account.source.language != null) + preferences.postingDefaultLanguage = account.source.language; + } + } + + public void reloadPreferences(Consumer callback){ + new GetPreferences() + .setCallback(new Callback<>(){ + @Override + public void onSuccess(Preferences result){ + preferences=result; + preferencesFromAccountSource(self); + if(callback!=null) + callback.accept(result); + AccountSessionManager.getInstance().writeAccountsFile(); + } + + @Override + public void onError(ErrorResponse error){ + Log.w(TAG, "Failed to load preferences for account "+getID()+": "+error); + } + }) + .exec(getID()); + } + + public SharedPreferences getRawLocalPreferences(){ + if(prefs==null) + prefs=MastodonApp.context.getSharedPreferences(getID(), Context.MODE_PRIVATE); + return prefs; + } + + public void reloadNotificationsMarker(Consumer callback){ + new GetMarkers() + .setCallback(new Callback<>(){ + @Override + public void onSuccess(TimelineMarkers result){ + if(result.notifications!=null && !TextUtils.isEmpty(result.notifications.lastReadId)){ + String id=result.notifications.lastReadId; + String lastKnown=getLastKnownNotificationsMarker(); + if(ObjectIdComparator.INSTANCE.compare(id, lastKnown)<0){ + // Marker moved back -- previous marker update must have failed. + // Pretend it didn't happen and repeat the request. + id=lastKnown; + new SaveMarkers(null, id).exec(getID()); + } + callback.accept(id); + setNotificationsMarker(id, false); + } + } + + @Override + public void onError(ErrorResponse error){} + }) + .exec(getID()); + } + + public String getLastKnownNotificationsMarker(){ + return getRawLocalPreferences().getString("notificationsMarker", null); + } + + public void setNotificationsMarker(String id, boolean clearUnread){ + getRawLocalPreferences().edit().putString("notificationsMarker", id).apply(); + E.post(new NotificationsMarkerUpdatedEvent(getID(), id, clearUnread)); + } + + public void logOut(Activity activity, Runnable onDone){ + new RevokeOauthToken(app.clientId, app.clientSecret, token.accessToken) + .setCallback(new Callback<>(){ + @Override + public void onSuccess(Object result){ + AccountSessionManager.getInstance().removeAccount(getID()); + onDone.run(); + } + + @Override + public void onError(ErrorResponse error){ + AccountSessionManager.getInstance().removeAccount(getID()); + onDone.run(); + } + }) + .wrapProgress(activity, R.string.loading, false) + .exec(getID()); + } + + public void savePreferencesLater(){ + preferencesNeedSaving=true; + } + + public void savePreferencesIfPending(){ + if(preferencesNeedSaving){ + new UpdateAccountCredentialsPreferences(preferences, null, null) + .setCallback(new Callback<>(){ + @Override + public void onSuccess(Account result){ + preferencesNeedSaving=false; + self=result; + AccountSessionManager.getInstance().writeAccountsFile(); + } + + @Override + public void onError(ErrorResponse error){ + Log.e(TAG, "failed to save preferences: "+error); + } + }) + .exec(getID()); + } + } + + public AccountLocalPreferences getLocalPreferences(){ + if(localPreferences==null) + localPreferences=new AccountLocalPreferences(getRawLocalPreferences(), this); + return localPreferences; + } + + public void filterStatuses(List statuses, FilterContext context){ + filterStatuses(statuses, context, null); + } + + public void filterStatuses(List statuses, FilterContext context, Account profile){ + filterStatusContainingObjects(statuses, Function.identity(), context, profile); + } + + public void filterStatusContainingObjects(List objects, Function extractor, FilterContext context){ + filterStatusContainingObjects(objects, extractor, context, null); + } + + public void filterStatusContainingObjects(List objects, Function extractor, FilterContext context, Account profile){ + Predicate statusIsOnOwnProfile = (s) -> self != null && profile != null && s.account != null + && Objects.equals(self.id, profile.id) && Objects.equals(self.id, s.account.id); + + if(getLocalPreferences().serverSideFiltersSupported){ + // Even with server-side filters, clients are expected to remove statuses that match a filter that hides them + objects.removeIf(o->{ + Status s=extractor.apply(o); + if(s==null) + return false; + if(s.filtered==null) + return false; + // don't hide own posts in own profile + if (statusIsOnOwnProfile.test(s)) + return false; + for(FilterResult filter:s.filtered){ + if(filter.filter.isActive() && filter.filter.filterAction==FilterAction.HIDE) + return true; + } + return false; + }); + return; + } + if(wordFilters==null) + return; + for(T obj:objects){ + Status s=extractor.apply(obj); + if(s!=null && s.filtered!=null){ + getLocalPreferences().serverSideFiltersSupported=true; + getLocalPreferences().save(); + return; + } + } + objects.removeIf(o->{ + Status s=extractor.apply(o); + if(s==null) + return false; + // don't hide own posts in own profile + if (statusIsOnOwnProfile.test(s)) + return false; + for(LegacyFilter filter:wordFilters){ + if(filter.context.contains(context) && filter.matches(s) && filter.isActive()) + return true; + } + return false; + }); + } + public Optional getInstance() { return Optional.ofNullable(AccountSessionManager.getInstance().getInstanceInfo(domain)); } diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/session/AccountSessionManager.java b/mastodon/src/main/java/org/joinmastodon/android/api/session/AccountSessionManager.java index b2631b39c..75c1e2d9b 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/api/session/AccountSessionManager.java +++ b/mastodon/src/main/java/org/joinmastodon/android/api/session/AccountSessionManager.java @@ -21,23 +21,18 @@ import org.joinmastodon.android.MastodonApp; import org.joinmastodon.android.R; import org.joinmastodon.android.api.MastodonAPIController; import org.joinmastodon.android.api.PushSubscriptionManager; -import org.joinmastodon.android.api.requests.accounts.GetPreferences; -import org.joinmastodon.android.api.requests.accounts.GetWordFilters; +import org.joinmastodon.android.api.requests.filters.GetLegacyFilters; import org.joinmastodon.android.api.requests.instance.GetCustomEmojis; import org.joinmastodon.android.api.requests.accounts.GetOwnAccount; import org.joinmastodon.android.api.requests.instance.GetInstance; -import org.joinmastodon.android.api.requests.markers.GetMarkers; import org.joinmastodon.android.api.requests.oauth.CreateOAuthApp; import org.joinmastodon.android.events.EmojiUpdatedEvent; import org.joinmastodon.android.model.Account; import org.joinmastodon.android.model.Application; import org.joinmastodon.android.model.Emoji; import org.joinmastodon.android.model.EmojiCategory; -import org.joinmastodon.android.model.Filter; +import org.joinmastodon.android.model.LegacyFilter; import org.joinmastodon.android.model.Instance; -import org.joinmastodon.android.model.Marker; -import org.joinmastodon.android.model.Markers; -import org.joinmastodon.android.model.Preferences; import org.joinmastodon.android.model.Token; import java.io.File; @@ -50,10 +45,10 @@ import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.Collections; import java.util.Comparator; -import java.util.EnumSet; import java.util.HashMap; import java.util.HashSet; import java.util.List; +import java.util.Optional; import java.util.Set; import java.util.stream.Collectors; @@ -155,11 +150,19 @@ public class AccountSessionManager{ return session; } + public static AccountSession get(String id){ + return getInstance().getAccount(id); + } + @Nullable public AccountSession tryGetAccount(String id){ return sessions.get(id); } + public static Optional getOptional(String id) { + return Optional.ofNullable(getInstance().tryGetAccount(id)); + } + @Nullable public AccountSession tryGetAccount(Account account) { return sessions.get(account.getDomainFromURL() + "_" + account.id); @@ -192,13 +195,19 @@ public class AccountSessionManager{ AccountSession session=getAccount(id); session.getCacheController().closeDatabase(); MastodonApp.context.deleteDatabase(id+".db"); - GlobalUserPreferences.removeAccount(id); + MastodonApp.context.getSharedPreferences(id, 0).edit().clear().commit(); + if(Build.VERSION.SDK_INT>=Build.VERSION_CODES.N){ + MastodonApp.context.deleteSharedPreferences(id); + }else{ + new File(MastodonApp.context.getDir("shared_prefs", Context.MODE_PRIVATE), id+".xml").delete(); + } sessions.remove(id); if(lastActiveAccountID.equals(id)){ if(sessions.isEmpty()) lastActiveAccountID=null; else lastActiveAccountID=getLoggedInAccounts().get(0).getID(); + prefs.edit().putString("lastActiveAccount", lastActiveAccountID).apply(); } writeAccountsFile(); String domain=session.domain.toLowerCase(); @@ -271,14 +280,13 @@ public class AccountSessionManager{ HashSet domains=new HashSet<>(); for(AccountSession session:sessions.values()){ domains.add(session.domain.toLowerCase()); - if(now-session.infoLastUpdated>24L*3600_000L || session == activeSession){ - updateSessionPreferences(session); + if(session == activeSession || now-session.infoLastUpdated>24L*3600_000L){ + session.reloadPreferences(null); updateSessionLocalInfo(session); } - if(now-session.filtersLastUpdated>3600_000L || session == activeSession){ + if(session == activeSession || (session.getLocalPreferences().serverSideFiltersSupported && now-session.filtersLastUpdated>3600_000L)){ updateSessionWordFilters(session); } - updateSessionMarkers(session); } if(loadedInstances){ maybeUpdateCustomEmojis(domains, activeSession != null ? activeSession.domain : null); @@ -289,20 +297,12 @@ public class AccountSessionManager{ long now=System.currentTimeMillis(); for(String domain:domains){ Long lastUpdated=instancesLastUpdated.get(domain); - if(lastUpdated==null || now-lastUpdated>24L*3600_000L || domain.equals(activeDomain)){ + if(domain.equals(activeDomain) || lastUpdated==null || now-lastUpdated>24L*3600_000L){ updateInstanceInfo(domain); } } } - private void preferencesFromSource(AccountSession session, Account account) { - if (account != null && account.source != null && session.preferences != null) { - if (account.source.privacy != null) - session.preferences.postingDefaultVisibility = account.source.privacy; - if (account.source.language != null) - session.preferences.postingDefaultLanguage = account.source.language; - } - } private void updateSessionLocalInfo(AccountSession session){ new GetOwnAccount() @@ -311,39 +311,7 @@ public class AccountSessionManager{ public void onSuccess(Account result){ session.self=result; session.infoLastUpdated=System.currentTimeMillis(); - preferencesFromSource(session, result); - writeAccountsFile(); - } - - @Override - public void onError(ErrorResponse error){} - }) - .exec(session.getID()); - } - - private void updateSessionPreferences(AccountSession session){ - new GetPreferences().setCallback(new Callback<>() { - @Override - public void onSuccess(Preferences preferences) { - session.preferences=preferences; - preferencesFromSource(session, session.self); - } - - @Override - public void onError(ErrorResponse error) { - session.preferences = new Preferences(); - preferencesFromSource(session, session.self); - } - }).exec(session.getID()); - } - - private void updateSessionWordFilters(AccountSession session){ - new GetWordFilters() - .setCallback(new Callback<>(){ - @Override - public void onSuccess(List result){ - session.wordFilters=result; - session.filtersLastUpdated=System.currentTimeMillis(); + session.preferencesFromAccountSource(result); writeAccountsFile(); } @@ -355,19 +323,22 @@ public class AccountSessionManager{ .exec(session.getID()); } - private void updateSessionMarkers(AccountSession session) { - new GetMarkers(EnumSet.allOf(Marker.Type.class)).setCallback(new Callback<>() { - @Override - public void onSuccess(Markers markers) { - session.markers = markers; - writeAccountsFile(); - } + private void updateSessionWordFilters(AccountSession session){ + new GetLegacyFilters() + .setCallback(new Callback<>(){ + @Override + public void onSuccess(List result){ + session.wordFilters=result; + session.filtersLastUpdated=System.currentTimeMillis(); + writeAccountsFile(); + } - @Override - public void onError(ErrorResponse error) { + @Override + public void onError(ErrorResponse error){ - } - }).exec(session.getID()); + } + }) + .exec(session.getID()); } public void updateInstanceInfo(String domain){ diff --git a/mastodon/src/main/java/org/joinmastodon/android/events/AllNotificationsSeenEvent.java b/mastodon/src/main/java/org/joinmastodon/android/events/AllNotificationsSeenEvent.java deleted file mode 100644 index aded8546a..000000000 --- a/mastodon/src/main/java/org/joinmastodon/android/events/AllNotificationsSeenEvent.java +++ /dev/null @@ -1,4 +0,0 @@ -package org.joinmastodon.android.events; - -public class AllNotificationsSeenEvent { -} diff --git a/mastodon/src/main/java/org/joinmastodon/android/events/NotificationReceivedEvent.java b/mastodon/src/main/java/org/joinmastodon/android/events/NotificationReceivedEvent.java deleted file mode 100644 index 7641a4bdb..000000000 --- a/mastodon/src/main/java/org/joinmastodon/android/events/NotificationReceivedEvent.java +++ /dev/null @@ -1,9 +0,0 @@ -package org.joinmastodon.android.events; - -public class NotificationReceivedEvent { - public String account, id; - public NotificationReceivedEvent(String account, String id) { - this.account = account; - this.id = id; - } -} diff --git a/mastodon/src/main/java/org/joinmastodon/android/events/NotificationsMarkerUpdatedEvent.java b/mastodon/src/main/java/org/joinmastodon/android/events/NotificationsMarkerUpdatedEvent.java new file mode 100644 index 000000000..f68a5b99d --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/events/NotificationsMarkerUpdatedEvent.java @@ -0,0 +1,13 @@ +package org.joinmastodon.android.events; + +public class NotificationsMarkerUpdatedEvent{ + public final String accountID; + public final String marker; + public final boolean clearUnread; + + public NotificationsMarkerUpdatedEvent(String accountID, String marker, boolean clearUnread){ + this.accountID=accountID; + this.marker=marker; + this.clearUnread=clearUnread; + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/events/SettingsFilterCreatedOrUpdatedEvent.java b/mastodon/src/main/java/org/joinmastodon/android/events/SettingsFilterCreatedOrUpdatedEvent.java new file mode 100644 index 000000000..2b85d9640 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/events/SettingsFilterCreatedOrUpdatedEvent.java @@ -0,0 +1,13 @@ +package org.joinmastodon.android.events; + +import org.joinmastodon.android.model.Filter; + +public class SettingsFilterCreatedOrUpdatedEvent{ + public final String accountID; + public final Filter filter; + + public SettingsFilterCreatedOrUpdatedEvent(String accountID, Filter filter){ + this.accountID=accountID; + this.filter=filter; + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/events/SettingsFilterDeletedEvent.java b/mastodon/src/main/java/org/joinmastodon/android/events/SettingsFilterDeletedEvent.java new file mode 100644 index 000000000..d89069ed8 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/events/SettingsFilterDeletedEvent.java @@ -0,0 +1,11 @@ +package org.joinmastodon.android.events; + +public class SettingsFilterDeletedEvent{ + public final String accountID; + public final String filterID; + + public SettingsFilterDeletedEvent(String accountID, String filterID){ + this.accountID=accountID; + this.filterID=filterID; + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/events/StatusCountersUpdatedEvent.java b/mastodon/src/main/java/org/joinmastodon/android/events/StatusCountersUpdatedEvent.java index d1f990585..e87e0fa20 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/events/StatusCountersUpdatedEvent.java +++ b/mastodon/src/main/java/org/joinmastodon/android/events/StatusCountersUpdatedEvent.java @@ -1,14 +1,17 @@ package org.joinmastodon.android.events; +import org.joinmastodon.android.api.CacheController; import org.joinmastodon.android.model.Status; public class StatusCountersUpdatedEvent{ public String id; public long favorites, reblogs, replies; public boolean favorited, reblogged, bookmarked, pinned; + public Status status; public StatusCountersUpdatedEvent(Status s){ id=s.id; + status=s; favorites=s.favouritesCount; reblogs=s.reblogsCount; replies=s.repliesCount; diff --git a/mastodon/src/main/java/org/joinmastodon/android/events/StatusDisplaySettingsChangedEvent.java b/mastodon/src/main/java/org/joinmastodon/android/events/StatusDisplaySettingsChangedEvent.java new file mode 100644 index 000000000..b4f63a098 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/events/StatusDisplaySettingsChangedEvent.java @@ -0,0 +1,9 @@ +package org.joinmastodon.android.events; + +public class StatusDisplaySettingsChangedEvent{ + public final String accountID; + + public StatusDisplaySettingsChangedEvent(String accountID){ + this.accountID=accountID; + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/AccountTimelineFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/AccountTimelineFragment.java index 67cd4df72..3451eb9a4 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/AccountTimelineFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/AccountTimelineFragment.java @@ -12,7 +12,7 @@ import org.joinmastodon.android.events.RemoveAccountPostsEvent; import org.joinmastodon.android.events.StatusCreatedEvent; import org.joinmastodon.android.events.StatusUnpinnedEvent; import org.joinmastodon.android.model.Account; -import org.joinmastodon.android.model.Filter; +import org.joinmastodon.android.model.FilterContext; import org.joinmastodon.android.model.Status; import org.joinmastodon.android.ui.displayitems.HeaderStatusDisplayItem; import org.joinmastodon.android.utils.StatusFilterPredicate; @@ -48,27 +48,22 @@ public class AccountTimelineFragment extends StatusListFragment{ @Override public void onAttach(Activity activity){ - super.onAttach(activity); user=Parcels.unwrap(getArguments().getParcelable("profileAccount")); filter=GetAccountStatuses.Filter.valueOf(getArguments().getString("filter")); + super.onAttach(activity); } @Override protected void doLoadData(int offset, int count){ - if(user==null) // TODO figure out why this happens - return; currentRequest=new GetAccountStatuses(user.id, offset>0 ? getMaxID() : null, null, count, filter) .setCallback(new SimpleCallback<>(this){ @Override public void onSuccess(List result){ if(getActivity()==null) return; AccountSessionManager asm = AccountSessionManager.getInstance(); - result=result.stream().filter(status -> { - // don't hide own posts in own profile - if (asm.isSelf(accountID, user) && asm.isSelf(accountID, status.account)) return true; - else return new StatusFilterPredicate(accountID, getFilterContext()).test(status); - }).collect(Collectors.toList()); - onDataLoaded(result, !result.isEmpty()); + boolean empty=result.isEmpty(); + AccountSessionManager.get(accountID).filterStatuses(result, getFilterContext(), user); + onDataLoaded(result, !empty); } }) .exec(accountID); @@ -77,6 +72,7 @@ public class AccountTimelineFragment extends StatusListFragment{ @Override public void onViewCreated(View view, Bundle savedInstanceState){ super.onViewCreated(view, savedInstanceState); + view.setBackground(null); // prevents unnecessary overdraw } @Override @@ -86,20 +82,20 @@ public class AccountTimelineFragment extends StatusListFragment{ loadData(); } - protected void onStatusCreated(StatusCreatedEvent ev){ + protected void onStatusCreated(Status status){ AccountSessionManager asm = AccountSessionManager.getInstance(); - if(!asm.isSelf(accountID, ev.status.account) || !asm.isSelf(accountID, user)) + if(!asm.isSelf(accountID, status.account) || !asm.isSelf(accountID, user)) return; if(filter==GetAccountStatuses.Filter.PINNED) return; if(filter==GetAccountStatuses.Filter.DEFAULT){ // Keep replies to self, discard all other replies - if(ev.status.inReplyToAccountId!=null && !ev.status.inReplyToAccountId.equals(AccountSessionManager.getInstance().getAccount(accountID).self.id)) + if(status.inReplyToAccountId!=null && !status.inReplyToAccountId.equals(AccountSessionManager.getInstance().getAccount(accountID).self.id)) return; }else if(filter==GetAccountStatuses.Filter.MEDIA){ - if(Optional.ofNullable(ev.status.mediaAttachments).map(List::isEmpty).orElse(true)) + if(Optional.ofNullable(status.mediaAttachments).map(List::isEmpty).orElse(true)) return; } - prependItems(Collections.singletonList(ev.status), true); + prependItems(Collections.singletonList(status), true); if (isOnTop()) scrollToTop(); } @@ -130,8 +126,8 @@ public class AccountTimelineFragment extends StatusListFragment{ @Override - protected Filter.FilterContext getFilterContext() { - return Filter.FilterContext.ACCOUNT; + protected FilterContext getFilterContext() { + return FilterContext.ACCOUNT; } @Override diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/BaseStatusListFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/BaseStatusListFragment.java index debbf06f2..b9fee44ac 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/BaseStatusListFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/BaseStatusListFragment.java @@ -6,14 +6,10 @@ import android.content.res.Configuration; import android.graphics.Canvas; import android.graphics.Paint; import android.graphics.Rect; -import android.graphics.Typeface; import android.graphics.drawable.Drawable; import android.os.Build; import android.os.Bundle; -import android.text.Layout; -import android.text.StaticLayout; -import android.text.TextPaint; -import android.text.TextUtils; +import android.util.Log; import android.view.View; import android.view.ViewGroup; import android.view.WindowInsets; @@ -26,6 +22,7 @@ import org.joinmastodon.android.GlobalUserPreferences; import org.joinmastodon.android.R; import org.joinmastodon.android.api.requests.accounts.GetAccountRelationships; import org.joinmastodon.android.api.requests.polls.SubmitPollVote; +import org.joinmastodon.android.api.session.AccountSessionManager; import org.joinmastodon.android.events.PollUpdatedEvent; import org.joinmastodon.android.model.Account; import org.joinmastodon.android.model.DisplayItemsParent; @@ -33,13 +30,16 @@ import org.joinmastodon.android.model.Poll; import org.joinmastodon.android.model.Relationship; import org.joinmastodon.android.model.Status; import org.joinmastodon.android.ui.BetterItemAnimator; +import org.joinmastodon.android.ui.displayitems.AccountStatusDisplayItem; import org.joinmastodon.android.ui.displayitems.ExtendedFooterStatusDisplayItem; import org.joinmastodon.android.ui.displayitems.FooterStatusDisplayItem; import org.joinmastodon.android.ui.displayitems.GapStatusDisplayItem; +import org.joinmastodon.android.ui.displayitems.HashtagStatusDisplayItem; import org.joinmastodon.android.ui.displayitems.HeaderStatusDisplayItem; import org.joinmastodon.android.ui.displayitems.MediaGridStatusDisplayItem; import org.joinmastodon.android.ui.displayitems.PollFooterStatusDisplayItem; import org.joinmastodon.android.ui.displayitems.PollOptionStatusDisplayItem; +import org.joinmastodon.android.ui.displayitems.SpoilerStatusDisplayItem; import org.joinmastodon.android.ui.displayitems.StatusDisplayItem; import org.joinmastodon.android.ui.displayitems.TextStatusDisplayItem; import org.joinmastodon.android.ui.displayitems.WarningFilteredStatusDisplayItem; @@ -54,6 +54,7 @@ import java.util.ArrayList; import java.util.Collections; import java.util.HashMap; import java.util.List; +import java.util.Objects; import java.util.Set; import java.util.stream.Collectors; @@ -68,10 +69,11 @@ import me.grishka.appkit.imageloader.ImageLoaderRecyclerAdapter; import me.grishka.appkit.imageloader.ImageLoaderViewHolder; import me.grishka.appkit.imageloader.requests.ImageLoaderRequest; import me.grishka.appkit.utils.BindableViewHolder; +import me.grishka.appkit.utils.MergeRecyclerAdapter; import me.grishka.appkit.utils.V; import me.grishka.appkit.views.UsableRecyclerView; -public abstract class BaseStatusListFragment extends RecyclerFragment implements PhotoViewerHost, ScrollableToTop, IsOnTop, HasFab, ProvidesAssistContent.ProvidesWebUri { +public abstract class BaseStatusListFragment extends MastodonRecyclerFragment implements PhotoViewerHost, ScrollableToTop, IsOnTop, HasFab, ProvidesAssistContent.ProvidesWebUri { protected ArrayList displayItems=new ArrayList<>(); protected DisplayItemsAdapter adapter; protected String accountID; @@ -96,7 +98,7 @@ public abstract class BaseStatusListFragment exten @Override public void onCreate(Bundle savedInstanceState){ super.onCreate(savedInstanceState); - if(GlobalUserPreferences.disableMarquee){ + if(GlobalUserPreferences.toolbarMarquee){ setTitleMarqueeEnabled(false); setSubtitleMarqueeEnabled(false); } @@ -350,7 +352,11 @@ public abstract class BaseStatusListFragment exten public void getSelectorBounds(View view, Rect outRect){ boolean hasDescendant = false, hasAncestor = false, isWarning = false; int lastIndex = -1, firstIndex = -1; - list.getDecoratedBoundsWithMargins(view, outRect); + if(((UsableRecyclerView) list).isIncludeMarginsInItemHitbox()){ + list.getDecoratedBoundsWithMargins(view, outRect); + }else{ + outRect.set(view.getLeft(), view.getTop(), view.getRight(), view.getBottom()); + } RecyclerView.ViewHolder holder=list.getChildViewHolder(view); if(holder instanceof StatusDisplayItem.Holder){ if(((StatusDisplayItem.Holder) holder).getItem().getType()==StatusDisplayItem.Type.GAP){ @@ -427,6 +433,9 @@ public abstract class BaseStatusListFragment exten } protected int getMainAdapterOffset(){ + if(list.getAdapter() instanceof MergeRecyclerAdapter mergeAdapter){ + return mergeAdapter.getPositionForAdapter(adapter); + } return 0; } @@ -438,6 +447,10 @@ public abstract class BaseStatusListFragment exten c.drawLine(0, y, parent.getWidth(), y, paint); } + protected boolean needDividerForExtraItem(View child, View bottomSibling, RecyclerView.ViewHolder holder, RecyclerView.ViewHolder siblingHolder){ + return false; + } + public abstract void onItemClick(String id); protected void updatePoll(String itemID, Status status, Poll poll){ @@ -525,38 +538,57 @@ public abstract class BaseStatusListFragment exten .exec(accountID); } - public void onRevealSpoilerClick(TextStatusDisplayItem.Holder holder){ + public void onRevealSpoilerClick(SpoilerStatusDisplayItem.Holder holder){ Status status=holder.getItem().status; - revealSpoiler(status, holder.getItemID()); + toggleSpoiler(status, holder.getItemID()); } - public void onRevealSpoilerClick(MediaGridStatusDisplayItem.Holder holder){ - Status status=holder.getItem().status; - revealSpoiler(status, holder.getItemID()); + public void onVisibilityIconClick(HeaderStatusDisplayItem.Holder holder) { + Status status = holder.getItem().status; + MediaGridStatusDisplayItem.Holder mediaGrid = findHolderOfType(holder.getItemID(), MediaGridStatusDisplayItem.Holder.class); + if (mediaGrid != null) { + if (!status.sensitiveRevealed) mediaGrid.revealSensitive(); + else mediaGrid.hideSensitive(); + } else { + // media grid's methods normally change the status' state - we still want to be able + // to do this if the media grid is not bound, tho - so, doing it ourselves here + status.sensitiveRevealed = !status.sensitiveRevealed; + } + holder.rebind(); } - protected void revealSpoiler(Status status, String itemID){ - status.spoilerRevealed=true; + public void onSensitiveRevealed(MediaGridStatusDisplayItem.Holder holder) { + HeaderStatusDisplayItem.Holder header = findHolderOfType(holder.getItemID(), HeaderStatusDisplayItem.Holder.class); + if(header != null) header.rebind(); + } + + protected void toggleSpoiler(Status status, String itemID){ + status.spoilerRevealed=!status.spoilerRevealed; + if (!status.spoilerRevealed && !AccountSessionManager.get(accountID).getLocalPreferences().revealCWs) + status.sensitiveRevealed = false; + + SpoilerStatusDisplayItem.Holder spoiler=findHolderOfType(itemID, SpoilerStatusDisplayItem.Holder.class); + if(spoiler!=null) + spoiler.rebind(); + SpoilerStatusDisplayItem spoilerItem=Objects.requireNonNull(findItemOfType(itemID, SpoilerStatusDisplayItem.class)); + + int index=displayItems.indexOf(spoilerItem); + if(status.spoilerRevealed){ + displayItems.addAll(index+1, spoilerItem.contentItems); + adapter.notifyItemRangeInserted(index+1, spoilerItem.contentItems.size()); + }else{ + displayItems.subList(index+1, index+1+spoilerItem.contentItems.size()).clear(); + adapter.notifyItemRangeRemoved(index+1, spoilerItem.contentItems.size()); + } + TextStatusDisplayItem.Holder text=findHolderOfType(itemID, TextStatusDisplayItem.Holder.class); if(text!=null) adapter.notifyItemChanged(text.getAbsoluteAdapterPosition()-getMainAdapterOffset()); HeaderStatusDisplayItem.Holder header=findHolderOfType(itemID, HeaderStatusDisplayItem.Holder.class); if(header!=null) header.rebind(); - updateImagesSpoilerState(status, itemID); - } - public void onVisibilityIconClick(HeaderStatusDisplayItem.Holder holder){ - Status status=holder.getItem().status; - status.spoilerRevealed=!status.spoilerRevealed; - if(!TextUtils.isEmpty(status.spoilerText)){ - TextStatusDisplayItem.Holder text = findHolderOfType(holder.getItemID(), TextStatusDisplayItem.Holder.class); - if(text!=null){ - adapter.notifyItemChanged(text.getAbsoluteAdapterPosition()); - } - } - holder.rebind(); - updateImagesSpoilerState(status, holder.getItemID()); + list.invalidateItemDecorations(); } public void onEnableExpandable(TextStatusDisplayItem.Holder holder, boolean expandable) { @@ -575,30 +607,6 @@ public abstract class BaseStatusListFragment exten if (header != null) header.rebind(); } - protected void updateImagesSpoilerState(Status status, String itemID){ - ArrayList updatedPositions=new ArrayList<>(); - MediaGridStatusDisplayItem.Holder mediaGrid=findHolderOfType(itemID, MediaGridStatusDisplayItem.Holder.class); - if(mediaGrid!=null){ - mediaGrid.setRevealed(status.spoilerRevealed); - updatedPositions.add(mediaGrid.getAbsoluteAdapterPosition()-getMainAdapterOffset()); - } - int i=0; - for(StatusDisplayItem item:displayItems){ - if(itemID.equals(item.parentID) && item instanceof MediaGridStatusDisplayItem && !updatedPositions.contains(i)){ - adapter.notifyItemChanged(i); - } - i++; - } - } - - public void onImageUpdated(MediaGridStatusDisplayItem.Holder holder, int index) { - holder.rebind(); - MediaGridStatusDisplayItem.Holder mediaGrid = findHolderOfType(holder.getItemID(), MediaGridStatusDisplayItem.Holder.class); - if(mediaGrid!=null){ - adapter.notifyItemChanged(mediaGrid.getAbsoluteAdapterPosition()); - } - } - public void onGapClick(GapStatusDisplayItem.Holder item){} public void onWarningClick(WarningFilteredStatusDisplayItem.Holder warning){ @@ -754,11 +762,26 @@ public abstract class BaseStatusListFragment exten assistContent.setWebUri(getWebUri(getSession().getInstanceUri().buildUpon())); } + public void rebuildAllDisplayItems(){ + displayItems.clear(); + for(T item:data){ + displayItems.addAll(buildDisplayItems(item)); + } + adapter.notifyDataSetChanged(); + } + + protected void onModifyItemViewHolder(BindableViewHolder holder){} + @Override protected void onDataLoaded(List d, boolean more) { + if (getContext()==null) return; super.onDataLoaded(d, more); // more available, but the page isn't even full yet? seems wrong, let's load some more - if (more && d.size() < itemsPerPage) preloader.onScrolledToLastItem(); + if (more && d.size() < itemsPerPage) { + Log.d("BaseStatusListFragment", "doing the 'loading more things' thing!!! ipp: "+itemsPerPage+", items size: "+ d.size()); + new Exception().printStackTrace(); + preloader.onScrolledToLastItem(); + } } protected class DisplayItemsAdapter extends UsableRecyclerView.Adapter> implements ImageLoaderRecyclerAdapter{ @@ -770,7 +793,9 @@ public abstract class BaseStatusListFragment exten @NonNull @Override public BindableViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType){ - return (BindableViewHolder) StatusDisplayItem.createViewHolder(StatusDisplayItem.Type.values()[viewType & (~0x80000000)], getActivity(), parent); + BindableViewHolder holder=(BindableViewHolder) StatusDisplayItem.createViewHolder(StatusDisplayItem.Type.values()[viewType & (~0x80000000)], getActivity(), parent, BaseStatusListFragment.this); + onModifyItemViewHolder(holder); + return holder; } @Override @@ -801,15 +826,12 @@ public abstract class BaseStatusListFragment exten } private class StatusListItemDecoration extends RecyclerView.ItemDecoration{ - private Paint dividerPaint=new Paint(), hiddenMediaPaint=new Paint(Paint.ANTI_ALIAS_FLAG); - private Typeface mediumTypeface=Typeface.create("sans-serif-medium", Typeface.NORMAL); - private Layout mediaHiddenTitleLayout, mediaHiddenTextLayout, tapToRevealTextLayout; - private int currentMediaHiddenLayoutsWidth=0; + private Paint dividerPaint=new Paint(); { - dividerPaint.setColor(UiUtils.getThemeColor(getActivity(), R.attr.colorPollVoted)); + dividerPaint.setColor(UiUtils.getThemeColor(getActivity(), R.attr.colorM3OutlineVariant)); dividerPaint.setStyle(Paint.Style.STROKE); - dividerPaint.setStrokeWidth(V.dp(1)); + dividerPaint.setStrokeWidth(V.dp(0.5f)); } @Override @@ -819,80 +841,23 @@ public abstract class BaseStatusListFragment exten View bottomSibling=parent.getChildAt(i+1); RecyclerView.ViewHolder holder=parent.getChildViewHolder(child); RecyclerView.ViewHolder siblingHolder=parent.getChildViewHolder(bottomSibling); - if(holder instanceof StatusDisplayItem.Holder ih && siblingHolder instanceof StatusDisplayItem.Holder sh - && (!ih.getItemID().equals(sh.getItemID()) || sh instanceof ExtendedFooterStatusDisplayItem.Holder) && ih.getItem().getType()!=StatusDisplayItem.Type.GAP){ - if (!ih.getItem().isMainStatus && ih.getItem().hasDescendantNeighbor) continue; + if(needDrawDivider(holder, siblingHolder)){ drawDivider(child, bottomSibling, holder, siblingHolder, parent, c, dividerPaint); } } } - @Override - public void onDrawOver(@NonNull Canvas c, @NonNull RecyclerView parent, @NonNull RecyclerView.State state){ - for(int i=0;i ih && siblingHolder instanceof StatusDisplayItem.Holder sh){ + // Do not draw dividers between hashtag and/or account rows + if((ih instanceof HashtagStatusDisplayItem.Holder || ih instanceof AccountStatusDisplayItem.Holder) && (sh instanceof HashtagStatusDisplayItem.Holder || sh instanceof AccountStatusDisplayItem.Holder)) + return false; + if (!ih.getItem().isMainStatus && ih.getItem().hasDescendantNeighbor) return false; + return (!ih.getItemID().equals(sh.getItemID()) || sh instanceof ExtendedFooterStatusDisplayItem.Holder) && ih.getItem().getType()!=StatusDisplayItem.Type.GAP; } - for(int i=0;i pollOptions=new ArrayList<>(); - - private ArrayList attachments=new ArrayList<>(); + private Button visibilityBtn; + private LinearLayout bottomBar; + private View autocompleteDivider; private List customEmojis; private CustomEmojiPopupKeyboard emojiKeyboard; @@ -209,61 +179,77 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr private Status quote; private String initialText; private String uuid; - private int pollDuration=24*3600; - private String pollDurationStr; private EditText spoilerEdit; + private View spoilerWrap; private boolean hasSpoiler; private boolean sensitive; private Instant scheduledAt = null; private ProgressBar sendProgress; - private ImageView sendError; private View sendingOverlay; private WindowManager wm; private StatusPrivacy statusVisibility=StatusPrivacy.PUBLIC; private boolean localOnly; private ComposeAutocompleteSpan currentAutocompleteSpan; private FrameLayout mainEditTextWrap; - private ComposeAutocompleteViewController autocompleteViewController; - private Instance instance; - private boolean attachmentsErrorShowing; - private Status editingStatus; + private ComposeLanguageAlertViewController.SelectedOption postLang; + + private ComposeAutocompleteViewController autocompleteViewController; + private ComposePollViewController pollViewController=new ComposePollViewController(this); + private ComposeMediaViewController mediaViewController=new ComposeMediaViewController(this); + public Instance instance; + + public Status editingStatus; private ScheduledStatus scheduledStatus; private boolean redraftStatus; - private boolean pollChanged; - private boolean creatingView; - private boolean ignoreSelectionChanges=false; - private Runnable updateUploadEtaRunnable; - private String language, encoding; private ContentType contentType; private MastodonLanguage.LanguageResolver languageResolver; + private boolean creatingView; + private boolean ignoreSelectionChanges=false; + private MenuItem actionItem; + private MenuItem draftMenuItem, undraftMenuItem, scheduleMenuItem, unscheduleMenuItem; + private boolean wasDetached; + + private BackgroundColorSpan overLimitBG; + private ForegroundColorSpan overLimitFG; + + public ComposeFragment(){ + super(R.layout.toolbar_fragment_with_progressbar); + } + @Override public void onCreate(Bundle savedInstanceState){ super.onCreate(savedInstanceState); setRetainInstance(true); accountID=getArguments().getString("account"); - contentType = GlobalUserPreferences.accountsDefaultContentTypes.get(accountID); + AccountSession session=AccountSessionManager.get(accountID); - AccountSession session=AccountSessionManager.getInstance().getAccount(accountID); self=session.self; instanceDomain=session.domain; customEmojis=AccountSessionManager.getInstance().getCustomEmojis(instanceDomain); instance=AccountSessionManager.getInstance().getInstanceInfo(instanceDomain); languageResolver=new MastodonLanguage.LanguageResolver(instance); redraftStatus=getArguments().getBoolean("redraftStatus", false); - if(getArguments().containsKey("editStatus")) + contentType=session.getLocalPreferences().defaultContentType; + if(getArguments().containsKey("editStatus")){ editingStatus=Parcels.unwrap(getArguments().getParcelable("editStatus")); - if(getArguments().containsKey("replyTo")) + } + if(getArguments().containsKey("replyTo")) { replyTo=Parcels.unwrap(getArguments().getParcelable("replyTo")); - if(getArguments().containsKey("quote")) + } + if(getArguments().containsKey("quote")) { quote=Parcels.unwrap(getArguments().getParcelable("quote")); + } if(instance==null){ Nav.finish(this); return; } + if(customEmojis.isEmpty()){ + AccountSessionManager.getInstance().updateInstanceInfo(instanceDomain); + } Bundle bundle = savedInstanceState != null ? savedInstanceState : getArguments(); if (bundle.containsKey("scheduledStatus")) scheduledStatus=Parcels.unwrap(bundle.getParcelable("scheduledStatus")); @@ -275,19 +261,16 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr charLimit=instance.configuration.statuses.maxCharacters; else charLimit=500; + +// setTitle(editingStatus==null ? R.string.new_post : R.string.edit_post); + if(savedInstanceState!=null) + postLang=Parcels.unwrap(savedInstanceState.getParcelable("postLang")); } @Override public void onDestroy(){ super.onDestroy(); - for(DraftMediaAttachment att:attachments){ - if(att.isUploadingOrProcessing()) - att.cancelUpload(); - } - if(updateUploadEtaRunnable!=null){ - UiUtils.removeCallbacks(updateUploadEtaRunnable); - updateUploadEtaRunnable=null; - } + mediaViewController.cancelAllUploads(); } @Override @@ -295,6 +278,15 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr super.onAttach(activity); setHasOptionsMenu(true); wm=activity.getSystemService(WindowManager.class); + + overLimitBG=new BackgroundColorSpan(UiUtils.getThemeColor(activity, R.attr.colorM3ErrorContainer)); + overLimitFG=new ForegroundColorSpan(UiUtils.getThemeColor(activity, R.attr.colorM3Error)); + } + + @Override + public void onDetach(){ + wasDetached=true; + super.onDetach(); } @SuppressLint("ClickableViewAccessibility") @@ -302,9 +294,21 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr public View onCreateContentView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState){ creatingView=true; emojiKeyboard=new CustomEmojiPopupKeyboard(getActivity(), customEmojis, instanceDomain); - emojiKeyboard.setListener(this::onCustomEmojiClick); + emojiKeyboard.setListener(new CustomEmojiPopupKeyboard.Listener(){ + @Override + public void onEmojiSelected(Emoji emoji){ + onCustomEmojiClick(emoji); + } + + @Override + public void onBackspace(){ + getActivity().dispatchKeyEvent(new KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_DEL)); + getActivity().dispatchKeyEvent(new KeyEvent(KeyEvent.ACTION_UP, KeyEvent.KEYCODE_DEL)); + } + }); View view=inflater.inflate(R.layout.fragment_compose, container, false); + mainLayout=view.findViewById(R.id.compose_main_ll); mainEditText=view.findViewById(R.id.toot_text); mainEditTextWrap=view.findViewById(R.id.toot_text_wrap); charCounter=view.findViewById(R.id.char_counter); @@ -317,7 +321,8 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr selfExtraText=view.findViewById(R.id.self_extra_text); HtmlParser.setTextWithCustomEmoji(selfName, self.displayName, self.emojis); selfUsername.setText('@'+self.username+'@'+instanceDomain); - ViewImageLoader.load(selfAvatar, null, new UrlImageLoaderRequest(self.avatar)); + if(self.avatar!=null) + ViewImageLoader.load(selfAvatar, null, new UrlImageLoaderRequest(self.avatar)); ViewOutlineProvider roundCornersOutline=new ViewOutlineProvider(){ @Override public void getOutline(View view, Outline outline){ @@ -326,6 +331,7 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr }; selfAvatar.setOutlineProvider(roundCornersOutline); selfAvatar.setClipToOutline(true); + bottomBar=view.findViewById(R.id.bottom_bar); mediaBtn=view.findViewById(R.id.btn_media); pollBtn=view.findViewById(R.id.btn_poll); @@ -337,13 +343,10 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr scheduleDraftText=view.findViewById(R.id.schedule_draft_text); scheduleDraftDismiss=view.findViewById(R.id.schedule_draft_dismiss); scheduleTimeBtn=view.findViewById(R.id.scheduled_time_btn); - sensitiveIcon=view.findViewById(R.id.sensitive_icon); - sensitiveItem=view.findViewById(R.id.sensitive_item); - replyText=view.findViewById(GlobalUserPreferences.replyLineAboveHeader ? R.id.reply_text : R.id.reply_text_below); - view.findViewById(GlobalUserPreferences.replyLineAboveHeader ? R.id.reply_text_below : R.id.reply_text) - .setVisibility(View.GONE); + sensitiveBtn=view.findViewById(R.id.sensitive_item); + replyText=view.findViewById(R.id.reply_text); - if (isPhotoPickerAvailable()) { + if (UiUtils.isPhotoPickerAvailable()) { PopupMenu attachPopup = new PopupMenu(getContext(), mediaBtn); attachPopup.inflate(R.menu.attach); attachPopup.setOnMenuItemClickListener(i -> { @@ -360,6 +363,9 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr pollBtn.setOnClickListener(v->togglePoll()); emojiBtn.setOnClickListener(v->emojiKeyboard.toggleKeyboardPopup(mainEditText)); spoilerBtn.setOnClickListener(v->toggleSpoiler()); + Drawable arrow=getResources().getDrawable(R.drawable.ic_baseline_arrow_drop_down_18, getActivity().getTheme()).mutate(); + arrow.setTint(UiUtils.getThemeColor(getActivity(), R.attr.colorM3OnSurface)); + visibilityBtn.setCompoundDrawablesRelativeWithIntrinsicBounds(null, null, arrow, null); localOnly = savedInstanceState != null ? savedInstanceState.getBoolean("localOnly") : editingStatus != null ? editingStatus.localOnly : replyTo != null && replyTo.localOnly; @@ -375,79 +381,40 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr scheduleDraftDismiss.setOnClickListener(v->updateScheduledAt(null)); scheduleTimeBtn.setOnClickListener(v->pickScheduledDateTime()); - sensitiveItem.setOnClickListener(v->toggleSensitive()); + sensitiveBtn.setOnClickListener(v->toggleSensitive()); emojiKeyboard.setOnIconChangedListener(new PopupKeyboard.OnIconChangeListener(){ @Override public void onIconChanged(int icon){ emojiBtn.setSelected(icon!=PopupKeyboard.ICON_HIDDEN); + updateNavigationBarColor(icon!=PopupKeyboard.ICON_HIDDEN); + if(autocompleteViewController.getMode()==ComposeAutocompleteViewController.Mode.EMOJIS){ + contentView.layout(contentView.getLeft(), contentView.getTop(), contentView.getRight(), contentView.getBottom()); + if(icon==PopupKeyboard.ICON_HIDDEN) + showAutocomplete(); + else + hideAutocomplete(); + } } }); contentView=(SizeListenerLinearLayout) view; contentView.addView(emojiKeyboard.getView()); - emojiKeyboard.getView().setElevation(V.dp(2)); - - attachmentsView=view.findViewById(R.id.attachments); - pollOptionsView=view.findViewById(R.id.poll_options); - pollWrap=view.findViewById(R.id.poll_wrap); - addPollOptionBtn=view.findViewById(R.id.add_poll_option); - pollAllowMultipleItem=view.findViewById(R.id.poll_allow_multiple); - pollAllowMultipleCheckbox=view.findViewById(R.id.poll_allow_multiple_checkbox); - pollAllowMultipleItem.setOnClickListener(v->this.togglePollAllowMultiple()); - - addPollOptionBtn.setOnClickListener(v->{ - createDraftPollOption().edit.requestFocus(); - updatePollOptionHints(); - }); - pollOptionsView.setDragListener(this::onSwapPollOptions); - pollDurationView=view.findViewById(R.id.poll_duration); - pollDurationView.setOnClickListener(v->showPollDurationMenu()); - - pollOptions.clear(); - if(savedInstanceState!=null && savedInstanceState.containsKey("pollOptions")){ - pollBtn.setSelected(true); - mediaBtn.setEnabled(false); - pollWrap.setVisibility(View.VISIBLE); - updatePollAllowMultiple(savedInstanceState.getBoolean("pollAllowMultiple", false)); - for(String oldText:savedInstanceState.getStringArrayList("pollOptions")){ - DraftPollOption opt=createDraftPollOption(); - opt.edit.setText(oldText); - } - updatePollOptionHints(); - pollDurationView.setText(getString(R.string.compose_poll_duration, pollDurationStr)); - }else if(savedInstanceState==null && editingStatus!=null && editingStatus.poll!=null){ - pollBtn.setSelected(true); - mediaBtn.setEnabled(false); - pollWrap.setVisibility(View.VISIBLE); - updatePollAllowMultiple(editingStatus.poll.multiple); - for(Poll.Option eopt:editingStatus.poll.options){ - DraftPollOption opt=createDraftPollOption(); - opt.edit.setText(eopt.title); - } - pollDuration=scheduledStatus == null - ? (int)editingStatus.poll.expiresAt.minus(System.currentTimeMillis(), ChronoUnit.MILLIS).getEpochSecond() - : Integer.parseInt(scheduledStatus.params.poll.expiresIn); - pollDurationStr=UiUtils.formatTimeLeft(getActivity(), scheduledStatus == null - ? editingStatus.poll.expiresAt - : Instant.now().plus(pollDuration, ChronoUnit.SECONDS)); - updatePollOptionHints(); - pollDurationView.setText(getString(R.string.compose_poll_duration, pollDurationStr)); - }else{ - pollDurationView.setText(getString(R.string.compose_poll_duration, pollDurationStr=getResources().getQuantityString(R.plurals.x_days, 1, 1))); - } spoilerEdit=view.findViewById(R.id.content_warning); - LayerDrawable spoilerBg=(LayerDrawable) spoilerEdit.getBackground().mutate(); - spoilerBg.setDrawableByLayerId(R.id.left_drawable, new SpoilerStripesDrawable()); - spoilerBg.setDrawableByLayerId(R.id.right_drawable, new SpoilerStripesDrawable()); - spoilerEdit.setBackground(spoilerBg); + spoilerWrap=view.findViewById(R.id.content_warning_wrap); + LayerDrawable spoilerBg=(LayerDrawable) spoilerWrap.getBackground().mutate(); + spoilerBg.setDrawableByLayerId(R.id.left_drawable, new SpoilerStripesDrawable(false)); + spoilerBg.setDrawableByLayerId(R.id.right_drawable, new SpoilerStripesDrawable(false)); + spoilerWrap.setBackground(spoilerBg); + spoilerWrap.setClipToOutline(true); + spoilerWrap.setOutlineProvider(OutlineProviders.roundedRect(8)); if((savedInstanceState!=null && savedInstanceState.getBoolean("hasSpoiler", false)) || hasSpoiler){ hasSpoiler=true; - spoilerEdit.setVisibility(View.VISIBLE); + spoilerWrap.setVisibility(View.VISIBLE); spoilerBtn.setSelected(true); }else if(editingStatus!=null && !TextUtils.isEmpty(editingStatus.spoilerText)){ hasSpoiler=true; - spoilerEdit.setVisibility(View.VISIBLE); + spoilerWrap.setVisibility(View.VISIBLE); spoilerEdit.setText(getArguments().getString("sourceSpoiler", editingStatus.spoilerText)); spoilerBtn.setSelected(true); } @@ -455,23 +422,8 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr sensitive = savedInstanceState==null && editingStatus != null ? editingStatus.sensitive : savedInstanceState!=null && savedInstanceState.getBoolean("sensitive", false); if (sensitive) { - sensitiveItem.setVisibility(View.VISIBLE); - sensitiveIcon.setSelected(true); - } - - if(savedInstanceState!=null && savedInstanceState.containsKey("attachments")){ - ArrayList serializedAttachments=savedInstanceState.getParcelableArrayList("attachments"); - for(Parcelable a:serializedAttachments){ - DraftMediaAttachment att=Parcels.unwrap(a); - attachmentsView.addView(createMediaAttachmentView(att)); - attachments.add(att); - } - attachmentsView.setVisibility(View.VISIBLE); - }else if(!attachments.isEmpty()){ - attachmentsView.setVisibility(View.VISIBLE); - for(DraftMediaAttachment att:attachments){ - attachmentsView.addView(createMediaAttachmentView(att)); - } + sensitiveBtn.setVisibility(View.VISIBLE); + sensitiveBtn.setSelected(true); } if (savedInstanceState != null) { @@ -492,7 +444,6 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr }).setChecked(true); visibilityPopup.getMenu().findItem(R.id.local_only).setChecked(localOnly); - if (savedInstanceState != null && savedInstanceState.containsKey("contentType")) { contentType = (ContentType) savedInstanceState.getSerializable("contentType"); } else if (getArguments().containsKey("sourceContentType")) { @@ -502,15 +453,37 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr } catch (IllegalArgumentException ignored) {} } - int contentTypeId = ContentType.getContentTypeRes(contentType); - contentTypePopup.getMenu().findItem(contentTypeId).setChecked(true); - contentTypeBtn.setSelected(contentTypeId != R.id.content_type_null && contentTypeId != R.id.content_type_plain); + int typeIndex=contentType.ordinal(); + contentTypePopup.getMenu().findItem(typeIndex).setChecked(true); + contentTypeBtn.setSelected(typeIndex != ContentType.UNSPECIFIED.ordinal() && typeIndex != ContentType.PLAIN.ordinal()); autocompleteViewController=new ComposeAutocompleteViewController(getActivity(), accountID); - autocompleteViewController.setCompletionSelectedListener(this::onAutocompleteOptionSelected); + autocompleteViewController.setCompletionSelectedListener(new ComposeAutocompleteViewController.AutocompleteListener(){ + @Override + public void onCompletionSelected(String completion){ + onAutocompleteOptionSelected(completion); + } + + @Override + public void onSetEmojiPanelOpen(boolean open){ + if(open!=emojiKeyboard.isVisible()) + emojiKeyboard.toggleKeyboardPopup(mainEditText); + } + + @Override + public void onLaunchAccountSearch(){ + Bundle args=new Bundle(); + args.putString("account", accountID); + Nav.goForResult(getActivity(), ComposeAccountSearchFragment.class, args, AUTOCOMPLETE_ACCOUNT_RESULT, ComposeFragment.this); + } + }); View autocompleteView=autocompleteViewController.getView(); - autocompleteView.setVisibility(View.GONE); - mainEditTextWrap.addView(autocompleteView, new FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, V.dp(178), Gravity.TOP)); + autocompleteView.setVisibility(View.INVISIBLE); + bottomBar.addView(autocompleteView, 0, new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, V.dp(56))); + autocompleteDivider=view.findViewById(R.id.bottom_bar_autocomplete_divider); + + pollViewController.setView(view, savedInstanceState); + mediaViewController.setView(view, savedInstanceState); creatingView=false; @@ -520,31 +493,16 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr @Override public void onSaveInstanceState(Bundle outState){ super.onSaveInstanceState(outState); - if(!pollOptions.isEmpty()){ - ArrayList opts=new ArrayList<>(); - for(DraftPollOption opt:pollOptions){ - opts.add(opt.edit.getText().toString()); - } - outState.putStringArrayList("pollOptions", opts); - outState.putInt("pollDuration", pollDuration); - outState.putString("pollDurationStr", pollDurationStr); - outState.putBoolean("pollAllowMultiple", pollAllowMultipleItem.isSelected()); - } - outState.putBoolean("sensitive", sensitive); - outState.putBoolean("localOnly", localOnly); + pollViewController.onSaveInstanceState(outState); + mediaViewController.onSaveInstanceState(outState); outState.putBoolean("hasSpoiler", hasSpoiler); - outState.putString("language", language); - if(!attachments.isEmpty()){ - ArrayList serializedAttachments=new ArrayList<>(attachments.size()); - for(DraftMediaAttachment att:attachments){ - serializedAttachments.add(Parcels.wrap(att)); - } - outState.putParcelableArrayList("attachments", serializedAttachments); - } outState.putSerializable("visibility", statusVisibility); - outState.putSerializable("contentType", contentType); - if (scheduledAt != null) outState.putSerializable("scheduledAt", scheduledAt); - if (scheduledStatus != null) outState.putParcelable("scheduledStatus", Parcels.wrap(scheduledStatus)); + outState.putParcelable("postLang", Parcels.wrap(postLang)); + if(currentAutocompleteSpan!=null){ + Editable e=mainEditText.getText(); + outState.putInt("autocompleteStart", e.getSpanStart(currentAutocompleteSpan)); + outState.putInt("autocompleteEnd", e.getSpanEnd(currentAutocompleteSpan)); + } } @Override @@ -558,9 +516,11 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr contentView.setSizeListener(emojiKeyboard::onContentViewSizeChanged); InputMethodManager imm=getActivity().getSystemService(InputMethodManager.class); mainEditText.requestFocus(); - view.postDelayed(()->{ - imm.showSoftInput(mainEditText, 0); - }, 100); + view.postDelayed(()->{ + imm.showSoftInput(mainEditText, 0); + }, 100); + sendProgress=view.findViewById(R.id.progress); + sendProgress.setVisibility(View.GONE); mainEditText.setSelectionListener(this); mainEditText.addTextChangedListener(new TextWatcher(){ @@ -624,7 +584,6 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr char prevChar=spanStart>0 ? editable.charAt(spanStart-1) : ' '; if(!matcher.find() || !Character.isWhitespace(prevChar)){ // invalid mention, remove editable.removeSpan(span); - continue; }else if(matcher.end()+spanStart{ Bundle args=new Bundle(); @@ -687,9 +647,6 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr Nav.go(getActivity(), ProfileFragment.class, args); }); - ((TextView) view.findViewById(R.id.name)).setText(status.account.displayName); - ((TextView) view.findViewById(R.id.username)).setText(status.account.getDisplayUsername()); - view.findViewById(R.id.visibility).setVisibility(View.GONE); Drawable visibilityIcon = getActivity().getDrawable(switch(status.visibility){ case PUBLIC -> R.drawable.ic_fluent_earth_20_regular; case UNLISTED -> R.drawable.ic_fluent_lock_open_20_regular; @@ -700,19 +657,40 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr ImageView moreBtn = view.findViewById(R.id.more); moreBtn.setImageDrawable(visibilityIcon); moreBtn.setBackground(null); - TextView timestamp = view.findViewById(R.id.timestamp); - if (status.editedAt!=null) timestamp.setText(getString(R.string.edited_timestamp, UiUtils.formatRelativeTimestamp(getContext(), status.editedAt))); - else if (status.createdAt!=null) timestamp.setText(UiUtils.formatRelativeTimestamp(getContext(), status.createdAt)); - else timestamp.setText(""); + + TextView name = view.findViewById(R.id.name); + name.setText(HtmlParser.parseCustomEmoji(status.account.displayName, status.account.emojis)); + UiUtils.loadCustomEmojiInTextView(name); + + String time = status==null || status.editedAt==null + ? UiUtils.formatRelativeTimestamp(getContext(), status.createdAt) + : getString(R.string.edited_timestamp, UiUtils.formatRelativeTimestamp(getContext(), status.editedAt)); + + String sepp = getString(R.string.sk_separator); + String username = status.account.getDisplayUsername(); + ((TextView) view.findViewById(R.id.time_and_username)).setText(time == null ? username : + username + " " + sepp + " " + time); if (status.spoilerText != null && !status.spoilerText.isBlank()) { - view.findViewById(R.id.spoiler_header).setVisibility(View.VISIBLE); - ((TextView) view.findViewById(R.id.spoiler_title_inline)).setText(status.spoilerText); + TextView replyToSpoiler = view.findViewById(R.id.reply_to_spoiler); + replyToSpoiler.setVisibility(View.VISIBLE); + replyToSpoiler.setText(status.spoilerText); + LayerDrawable spoilerBg=(LayerDrawable) replyToSpoiler.getBackground().mutate(); + spoilerBg.setDrawableByLayerId(R.id.left_drawable, new SpoilerStripesDrawable(false)); + spoilerBg.setDrawableByLayerId(R.id.right_drawable, new SpoilerStripesDrawable(false)); + replyToSpoiler.setBackground(spoilerBg); + replyToSpoiler.setClipToOutline(true); + replyToSpoiler.setOutlineProvider(OutlineProviders.roundedRect(8)); } SpannableStringBuilder content = HtmlParser.parse(status.content, status.emojis, status.mentions, status.tags, accountID); LinkedTextView text = view.findViewById(R.id.text); - if (content.length() > 0) text.setText(content); - else view.findViewById(R.id.display_item_text).setLayoutParams(new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, V.dp(16))); + if (content.length() > 0) { + text.setText(content); + UiUtils.loadCustomEmojiInTextView(text); + } else { + view.findViewById(R.id.display_item_text) + .setLayoutParams(new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, V.dp(16))); + } replyText.setText(getString(quote!=null? R.string.sk_quoting_user : R.string.in_reply_to, status.account.displayName)); int visibilityNameRes = switch (status.visibility) { @@ -722,7 +700,7 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr case DIRECT -> R.string.visibility_private; case LOCAL -> R.string.sk_local_only; }; - replyText.setContentDescription(getString(R.string.in_reply_to, status.account.displayName) + ". " + getString(R.string.post_visibility) + ": " + getString(visibilityNameRes)); + replyText.setContentDescription(getString(R.string.in_reply_to, status.account.displayName) + ", " + getString(visibilityNameRes)); replyText.setOnClickListener(v->{ scrollView.smoothScrollTo(0, 0); }); @@ -746,20 +724,16 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr ignoreSelectionChanges=false; if(!TextUtils.isEmpty(status.spoilerText)){ hasSpoiler=true; - spoilerEdit.setVisibility(View.VISIBLE); - if ((GlobalUserPreferences.prefixReplies == ALWAYS + spoilerWrap.setVisibility(View.VISIBLE); + String prefix = (GlobalUserPreferences.prefixReplies == ALWAYS || (GlobalUserPreferences.prefixReplies == TO_OTHERS && !ownID.equals(status.account.id))) - && !status.spoilerText.startsWith("re: ")) { - spoilerEdit.setText("re: " + status.spoilerText); - } else { - spoilerEdit.setText(status.spoilerText); - } + && !status.spoilerText.startsWith("re: ") ? "re: " : ""; + spoilerEdit.setText(prefix + replyTo.spoilerText); spoilerBtn.setSelected(true); } - if (status.language != null && !status.language.isEmpty()) updateLanguage(status.language); + if (status.language != null && !status.language.isEmpty()) setPostLanguage(status.language); } }else if (editingStatus==null || editingStatus.inReplyToId==null){ - // TODO: remove workaround after https://github.com/mastodon/mastodon-android/issues/341 gets fixed replyText.setVisibility(View.GONE); } if(savedInstanceState==null){ @@ -769,20 +743,8 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr ignoreSelectionChanges=true; mainEditText.setSelection(mainEditText.length()); ignoreSelectionChanges=false; - updateLanguage(editingStatus.language); - if(!editingStatus.mediaAttachments.isEmpty()){ - attachmentsView.setVisibility(View.VISIBLE); - for(Attachment att:editingStatus.mediaAttachments){ - DraftMediaAttachment da=new DraftMediaAttachment(); - da.serverAttachment=att; - da.description=att.description; - da.uri=att.previewUrl!=null ? Uri.parse(att.previewUrl) : null; - da.state=AttachmentUploadState.DONE; - attachmentsView.addView(createMediaAttachmentView(da)); - attachments.add(da); - } - pollBtn.setEnabled(false); - } + setPostLanguage(editingStatus.language); + mediaViewController.onViewCreated(savedInstanceState);; }else{ String prefilledText=getArguments().getString("prefilledText"); if(!TextUtils.isEmpty(prefilledText)){ @@ -800,7 +762,7 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr ArrayList mediaUris=getArguments().getParcelableArrayList("mediaAttachments"); if(mediaUris!=null && !mediaUris.isEmpty()){ for(Uri uri:mediaUris){ - addMediaAttachment(uri, null); + mediaViewController.addMediaAttachment(uri, null); } } } @@ -813,15 +775,28 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr updateCharCounter(); visibilityBtn.setEnabled(redraftStatus); } + updateMediaPollStates(); + } + + @Override + public void onViewStateRestored(Bundle savedInstanceState){ + super.onViewStateRestored(savedInstanceState); + if(savedInstanceState!=null && savedInstanceState.containsKey("autocompleteStart")){ + int start=savedInstanceState.getInt("autocompleteStart"), end=savedInstanceState.getInt("autocompleteEnd"); + currentAutocompleteSpan=new ComposeAutocompleteSpan(); + mainEditText.getText().setSpan(currentAutocompleteSpan, start, end, Editable.SPAN_EXCLUSIVE_INCLUSIVE); + startAutocomplete(currentAutocompleteSpan); + } } @Override public void onCreateOptionsMenu(Menu menu, MenuInflater inflater){ - MenuItem item=menu.add(editingStatus==null ? R.string.publish : R.string.save); + inflater.inflate(editingStatus==null ? R.menu.compose : R.menu.compose_edit, menu); + actionItem = menu.findItem(R.id.publish); LinearLayout wrap=new LinearLayout(getActivity()); getActivity().getLayoutInflater().inflate(R.layout.compose_action, wrap); - item.setActionView(wrap); - item.setShowAsAction(MenuItem.SHOW_AS_ACTION_ALWAYS); + actionItem.setActionView(wrap); + actionItem.setShowAsAction(MenuItem.SHOW_AS_ACTION_ALWAYS); draftsBtn = wrap.findViewById(R.id.drafts_btn); draftOptionsPopup = new PopupMenu(getContext(), draftsBtn); @@ -842,25 +817,31 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr publishButton = wrap.findViewById(R.id.publish_btn); languageButton = wrap.findViewById(R.id.language_btn); - sendProgress = wrap.findViewById(R.id.send_progress); - sendError = wrap.findViewById(R.id.send_error); + languageButton.setOnClickListener(v->showLanguageAlert()); - publishButton.setOnClickListener(this::onPublishClick); + publishButton.setOnClickListener(v -> { + if(GlobalUserPreferences.altTextReminders && editingStatus==null) + checkAltTextsAndPublish(); + else + publish(); + }); draftsBtn.setOnClickListener(v-> draftOptionsPopup.show()); draftsBtn.setOnTouchListener(draftOptionsPopup.getDragToOpenListener()); updateScheduledAt(scheduledAt != null ? scheduledAt : scheduledStatus != null ? scheduledStatus.scheduledAt : null); - buildLanguageSelector(languageButton); + + Preferences prefs = AccountSessionManager.get(accountID).preferences; + if (postLang != null) setPostLanguage(postLang); + else setPostLanguage(prefs != null && prefs.postingDefaultLanguage != null && prefs.postingDefaultLanguage.length() > 0 + ? languageResolver.fromOrFallback(prefs.postingDefaultLanguage) + : languageResolver.getDefault()); if (isInstancePixelfed()) spoilerBtn.setVisibility(View.GONE); if (isInstancePixelfed() || (editingStatus != null && scheduledStatus == null)) { // editing an already published post draftsBtn.setVisibility(View.GONE); } - } - @Override - public String getAccountID() { - return accountID; + updatePublishButtonState(); } private void navigateToUnsentPosts() { @@ -879,86 +860,6 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr } } - private void updateLanguage(String lang) { - updateLanguage(lang == null ? languageResolver.getDefault() : languageResolver.from(lang)); - } - - private void updateLanguage(MastodonLanguage loc) { - updateLanguage(loc.getLanguage(), loc.getLanguageName(), loc.getDefaultName()); - } - - private void updateLanguage(String languageTag, String languageName, String defaultName) { - language = languageTag; - languageButton.setText(languageName); - languageButton.setContentDescription(getActivity().getString(R.string.sk_post_language, defaultName)); - } - - @SuppressLint("ClickableViewAccessibility") - private void buildLanguageSelector(Button btn) { - languagePopup=new PopupMenu(getActivity(), languageButton); - btn.setOnTouchListener(languagePopup.getDragToOpenListener()); - btn.setOnClickListener(v->languagePopup.show()); - - Preferences prefs = AccountSessionManager.getInstance().getAccount(accountID).preferences; - if (language != null) updateLanguage(language); - else updateLanguage(prefs != null && prefs.postingDefaultLanguage != null && prefs.postingDefaultLanguage.length() > 0 - ? languageResolver.from(prefs.postingDefaultLanguage) - : languageResolver.getDefault()); - - Menu languageMenu = languagePopup.getMenu(); - for (String recentLanguage : Optional.ofNullable(recentLanguages.get(accountID)).orElse(defaultRecentLanguages)) { - if (recentLanguage.equals("bottom")) { - addBottomLanguage(languageMenu); - } else { - MastodonLanguage l = languageResolver.from(recentLanguage); - languageMenu.add(0, allLanguages.indexOf(l), Menu.NONE, getActivity().getString(R.string.sk_language_name, l.getDefaultName(), l.getLanguageName())); - } - } - - SubMenu allLanguagesMenu = languageMenu.addSubMenu(R.string.sk_available_languages); - for (int i = 0; i < allLanguages.size(); i++) { - MastodonLanguage l = allLanguages.get(i); - allLanguagesMenu.add(0, i, Menu.NONE, getActivity().getString(R.string.sk_language_name, l.getDefaultName(), l.getLanguageName())); - } - - if (GlobalUserPreferences.bottomEncoding) addBottomLanguage(allLanguagesMenu); - - btn.setOnLongClickListener(v->{ - btn.performHapticFeedback(HapticFeedbackConstants.LONG_PRESS); - if (!GlobalUserPreferences.bottomEncoding) addBottomLanguage(allLanguagesMenu); - return false; - }); - - languagePopup.setOnMenuItemClickListener(i->{ - if (i.hasSubMenu()) return false; - if (i.getItemId() == allLanguages.size()) { - updateLanguage(language, "\uD83E\uDD7A\uD83D\uDC49\uD83D\uDC48", "bottom"); - encoding = "bottom"; - } else { - updateLanguage(allLanguages.get(i.getItemId())); - encoding = null; - } - return true; - }); - } - - private int getContentTypeName(String id) { - return switch (id) { - case "text/plain" -> R.string.sk_content_type_plain; - case "text/html" -> R.string.sk_content_type_html; - case "text/markdown" -> R.string.sk_content_type_markdown; - case "text/bbcode" -> R.string.sk_content_type_bbcode; - case "text/x.misskeymarkdown" -> R.string.sk_content_type_mfm; - default -> throw new IllegalArgumentException("Invalid content type"); - }; - } - - private void addBottomLanguage(Menu menu) { - if (menu.findItem(allLanguages.size()) == null) { - menu.add(0, allLanguages.size(), Menu.NONE, "bottom (\uD83E\uDD7A\uD83D\uDC49\uD83D\uDC48)"); - } - } - @Override public boolean onOptionsItemSelected(MenuItem item){ return true; @@ -972,7 +873,7 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr @SuppressLint("NewApi") private void updateCharCounter(){ - CharSequence text=mainEditText.getText(); + Editable text=mainEditText.getText(); String countableText=TwitterTextEmojiRegex.VALID_EMOJI_PATTERN.matcher( MENTION_PATTERN.matcher( @@ -988,43 +889,54 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr if(hasSpoiler){ charCount+=spoilerEdit.length(); } - if (localOnly && GlobalUserPreferences.accountsInGlitchMode.contains(accountID)) { + if (localOnly && AccountSessionManager.get(accountID).getLocalPreferences().glitchInstance) { charCount -= GLITCH_LOCAL_ONLY_SUFFIX.length(); } charCounter.setText(String.valueOf(charLimit-charCount)); + + text.removeSpan(overLimitBG); + text.removeSpan(overLimitFG); + if(charCount>charLimit){ + charCounter.setTextColor(UiUtils.getThemeColor(getActivity(), R.attr.colorM3Error)); + int start=text.length()-(charCount-charLimit); + int end=text.length(); + text.setSpan(overLimitFG, start, end, 0); + text.setSpan(overLimitBG, start, end, 0); + }else{ + charCounter.setTextColor(UiUtils.getThemeColor(getActivity(), R.attr.colorM3OnSurface)); + } + trimmedCharCount=text.toString().trim().length(); updatePublishButtonState(); } private void resetPublishButtonText() { int publishText = editingStatus==null || redraftStatus ? R.string.publish : R.string.save; - if (publishText == R.string.publish && !GlobalUserPreferences.publishButtonText.isEmpty()) { - publishButton.setText(GlobalUserPreferences.publishButtonText); + AccountLocalPreferences prefs=AccountSessionManager.get(accountID).getLocalPreferences(); + if (publishText == R.string.publish && !TextUtils.isEmpty(prefs.publishButtonText)) { + publishButton.setText(prefs.publishButtonText); } else { publishButton.setText(publishText); } } - private void updatePublishButtonState(){ + public void updatePublishButtonState(){ uuid=null; - int nonEmptyPollOptionsCount=0; - for(DraftPollOption opt:pollOptions){ - if(opt.edit.length()>0) - nonEmptyPollOptionsCount++; - } if(publishButton==null) return; - int nonDoneAttachmentCount=0; - for(DraftMediaAttachment att:attachments){ - if(att.state!=AttachmentUploadState.DONE) - nonDoneAttachmentCount++; - } - publishButton.setEnabled((!isInstancePixelfed() || attachments.size() > 0) && (trimmedCharCount>0 || !attachments.isEmpty()) && charCount<=charLimit && nonDoneAttachmentCount==0 && (pollOptions.isEmpty() || nonEmptyPollOptionsCount>1)); - sendError.setVisibility(View.GONE); + publishButton.setEnabled((!isInstancePixelfed() || !mediaViewController.isEmpty()) && (trimmedCharCount>0 || !mediaViewController.isEmpty()) && charCount<=charLimit && mediaViewController.getNonDoneAttachmentCount()==0 && (pollViewController.isEmpty() || pollViewController.getNonEmptyOptionsCount()>1)); } private void onCustomEmojiClick(Emoji emoji){ if(getActivity().getCurrentFocus() instanceof EditText edit){ + if(edit==mainEditText && currentAutocompleteSpan!=null && autocompleteViewController.getMode()==ComposeAutocompleteViewController.Mode.EMOJIS){ + Editable text=mainEditText.getText(); + int start=text.getSpanStart(currentAutocompleteSpan); + int end=text.getSpanEnd(currentAutocompleteSpan); + finishAutocomplete(); + text.replace(start, end, ':'+emoji.shortcode+':'); + return; + } int start=edit.getSelectionStart(); String prefix=start>0 && !Character.isWhitespace(edit.getText().charAt(start-1)) ? " :" : ":"; edit.getText().replace(start, edit.getSelectionEnd(), prefix+emoji.shortcode+':'); @@ -1034,20 +946,26 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr @Override protected void updateToolbar(){ super.updateToolbar(); - getToolbar().setNavigationIcon(R.drawable.ic_fluent_dismiss_24_regular); + int color=UiUtils.alphaBlendThemeColors(getActivity(), R.attr.colorM3Background, R.attr.colorM3Primary, 0.11f); + getToolbar().setBackgroundColor(color); + setStatusBarColor(color); + bottomBar.setBackgroundColor(color); + updateNavigationBarColor(emojiKeyboard.isVisible()); } - private void onPublishClick(View v){ - publish(); + private void updateNavigationBarColor(boolean emojiKeyboardVisible){ + int color=UiUtils.alphaBlendThemeColors(getActivity(), R.attr.colorM3Background, R.attr.colorM3Primary, emojiKeyboardVisible ? 0.08f : 0.11f); + setNavigationBarColor(color); } - private void publishErrorCallback(ErrorResponse error) { - wm.removeView(sendingOverlay); - sendingOverlay=null; - sendProgress.setVisibility(View.GONE); - sendError.setVisibility(View.VISIBLE); - publishButton.setEnabled(true); - if (error != null) error.showToast(getActivity()); + @Override + protected int getNavigationIconDrawableResource(){ + return R.drawable.ic_baseline_close_24; + } + + @Override + public boolean wantsCustomNavigationIcon(){ + return true; } private void createScheduledStatusFinish(ScheduledStatus result) { @@ -1070,7 +988,7 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr @Override public void onError(ErrorResponse error) { - publishErrorCallback(error); + handlePublishError(error); } }).exec(accountID); } else { @@ -1078,75 +996,29 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr } } - private void publish(){ - publish(false); + private void checkAltTextsAndPublish(){ + int count=mediaViewController.getMissingAltTextAttachmentCount(); + if(count==0){ + publish(); + }else{ + String msg=getResources().getQuantityString(mediaViewController.areAllAttachmentsImages() ? R.plurals.alt_text_reminder_x_images : R.plurals.alt_text_reminder_x_attachments, + count, switch(count){ + case 1 -> getString(R.string.count_one); + case 2 -> getString(R.string.count_two); + case 3 -> getString(R.string.count_three); + case 4 -> getString(R.string.count_four); + default -> String.valueOf(count); + }); + new M3AlertDialogBuilder(getActivity()) + .setTitle(R.string.alt_text_reminder_title) + .setMessage(msg) + .setPositiveButton(R.string.alt_text_reminder_post_anyway, (dlg, item)->publish()) + .setNegativeButton(R.string.cancel, null) + .show(); + } } - private void publish(boolean force){ - String text=mainEditText.getText().toString(); - CreateStatus.Request req=new CreateStatus.Request(); - if ("bottom".equals(encoding)) { - text = new StatusTextEncoder(Bottom::encode).encode(text); - req.spoilerText = "bottom-encoded emoji spam"; - } - if (localOnly && - GlobalUserPreferences.accountsInGlitchMode.contains(accountID) && - !GLITCH_LOCAL_ONLY_PATTERN.matcher(text).matches()) { - text += " " + GLITCH_LOCAL_ONLY_SUFFIX; - } - req.status=text; - req.localOnly=localOnly; - req.visibility=localOnly && instance.isAkkoma() ? StatusPrivacy.LOCAL : statusVisibility; - req.sensitive=sensitive; - req.language=language; - req.contentType=contentType; - req.scheduledAt = scheduledAt; - if(!attachments.isEmpty()){ - req.mediaIds=attachments.stream().map(a->a.serverAttachment.id).collect(Collectors.toList()); - Optional withoutAltText = attachments.stream().filter(a -> a.description == null || a.description.isBlank()).findFirst(); - boolean isDraft = scheduledAt != null && scheduledAt.isAfter(DRAFTS_AFTER_INSTANT); - if (!force && !GlobalUserPreferences.disableAltTextReminder && !isDraft && withoutAltText.isPresent()) { - new M3AlertDialogBuilder(getActivity()) - .setTitle(R.string.sk_alt_text_missing_title) - .setMessage(R.string.sk_alt_text_missing) - .setPositiveButton(R.string.add_alt_text, (d, w) -> editMediaDescription(withoutAltText.get())) - .setNegativeButton(R.string.sk_publish_anyway, (d, w) -> publish(true)) - .show(); - return; - } - } - // ask whether to publish now when editing an existing draft - if (!force && editingStatus != null && scheduledAt != null && scheduledAt.isAfter(DRAFTS_AFTER_INSTANT)) { - new M3AlertDialogBuilder(getActivity()) - .setTitle(R.string.sk_save_draft) - .setMessage(R.string.sk_save_draft_message) - .setPositiveButton(R.string.save, (d, w) -> publish(true)) - .setNegativeButton(R.string.publish, (d, w) -> { - updateScheduledAt(null); - publish(); - }) - .show(); - return; - } - if(replyTo!=null || (editingStatus != null && editingStatus.inReplyToId!=null)){ - req.inReplyToId=editingStatus!=null ? editingStatus.inReplyToId : replyTo.id; - } - if(!pollOptions.isEmpty()){ - req.poll=new CreateStatus.Request.Poll(); - req.poll.expiresIn=pollDuration; - req.poll.multiple=pollAllowMultipleItem.isSelected(); - for(DraftPollOption opt:pollOptions) - req.poll.options.add(opt.edit.getText().toString()); - } - if(hasSpoiler && spoilerEdit.length()>0){ - req.spoilerText=spoilerEdit.getText().toString(); - } - if(quote != null){ - req.quoteId=quote.id; - } - if(uuid==null) - uuid=UUID.randomUUID().toString(); - + private void publish(){ sendingOverlay=new View(getActivity()); WindowManager.LayoutParams overlayParams=new WindowManager.LayoutParams(); overlayParams.type=WindowManager.LayoutParams.TYPE_APPLICATION_PANEL; @@ -1158,18 +1030,75 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr wm.addView(sendingOverlay, overlayParams); publishButton.setEnabled(false); - sendProgress.setVisibility(View.VISIBLE); - sendError.setVisibility(View.GONE); + V.setVisibilityAnimated(sendProgress, View.VISIBLE); - Callback resCallback = new Callback<>(){ + mediaViewController.saveAltTextsBeforePublishing(this::actuallyPublish, this::handlePublishError); + } + + private void actuallyPublish(){ + actuallyPublish(false); + } + private void actuallyPublish(boolean force){ + String text=mainEditText.getText().toString(); + CreateStatus.Request req=new CreateStatus.Request(); + if ("bottom".equals(postLang.encoding)) { + text = new StatusTextEncoder(Bottom::encode).encode(text); + req.spoilerText = "bottom-encoded emoji spam"; + } + if (localOnly && + AccountSessionManager.get(accountID).getLocalPreferences().glitchInstance && + !GLITCH_LOCAL_ONLY_PATTERN.matcher(text).matches()) { + text += " " + GLITCH_LOCAL_ONLY_SUFFIX; + } + req.status=text; + req.localOnly=localOnly; + req.visibility=localOnly && instance.isAkkoma() ? StatusPrivacy.LOCAL : statusVisibility; + req.sensitive=sensitive; + req.contentType=contentType==ContentType.UNSPECIFIED ? null : contentType; + req.scheduledAt=scheduledAt; + if(!mediaViewController.isEmpty()){ + req.mediaIds=mediaViewController.getAttachmentIDs(); + } + // ask whether to publish now when editing an existing draft + if (!force && editingStatus != null && scheduledAt != null && scheduledAt.isAfter(DRAFTS_AFTER_INSTANT)) { + new M3AlertDialogBuilder(getActivity()) + .setTitle(R.string.sk_save_draft) + .setMessage(R.string.sk_save_draft_message) + .setPositiveButton(R.string.save, (d, w) -> actuallyPublish(true)) + .setNegativeButton(R.string.publish, (d, w) -> { + updateScheduledAt(null); + actuallyPublish(); + }) + .show(); + return; + } + if(replyTo!=null || (editingStatus != null && editingStatus.inReplyToId!=null)){ + req.inReplyToId=editingStatus!=null ? editingStatus.inReplyToId : replyTo.id; + } + if(!pollViewController.isEmpty()){ + req.poll=pollViewController.getPollForRequest(); + } + if(hasSpoiler && spoilerEdit.length()>0){ + req.spoilerText=spoilerEdit.getText().toString(); + } + if(postLang!=null && postLang.language!=null){ + req.language=postLang.language.getLanguage(); + } + if(quote != null){ + req.quoteId=quote.id; + } + if(uuid==null) + uuid=UUID.randomUUID().toString(); + + Callback resCallback=new Callback<>(){ @Override public void onSuccess(Status result){ maybeDeleteScheduledPost(() -> { wm.removeView(sendingOverlay); sendingOverlay=null; - if(editingStatus==null){ + if(editingStatus==null || redraftStatus){ E.post(new StatusCreatedEvent(result, accountID)); - if(replyTo!=null){ + if(replyTo!=null && !redraftStatus){ replyTo.repliesCount++; E.post(new StatusCountersUpdatedEvent(replyTo)); } @@ -1201,7 +1130,7 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr @Override public void onError(ErrorResponse error){ - publishErrorCallback(error); + handlePublishError(error); } }; @@ -1229,7 +1158,7 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr @Override public void onError(ErrorResponse error) { - publishErrorCallback(error); + handlePublishError(error); } }).exec(accountID); }else{ @@ -1238,43 +1167,59 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr .setMessage(R.string.sk_scheduled_too_soon) .setPositiveButton(R.string.ok, (a, b)->{}) .show(); - publishErrorCallback(null); + handlePublishError(null); publishButton.setEnabled(false); } - if (replyTo == null) { - List newRecentLanguages = new ArrayList<>(Optional.ofNullable(recentLanguages.get(accountID)).orElse(defaultRecentLanguages)); - newRecentLanguages.remove(language); - newRecentLanguages.add(0, language); - if (encoding != null) { - newRecentLanguages.remove(encoding); - newRecentLanguages.add(0, encoding); - } - if ("bottom".equals(encoding) && !GlobalUserPreferences.bottomEncoding) { - GlobalUserPreferences.bottomEncoding = true; - GlobalUserPreferences.save(); - } - recentLanguages.put(accountID, newRecentLanguages.stream().limit(4).collect(Collectors.toList())); - GlobalUserPreferences.save(); + if (replyTo == null) updateRecentLanguages(); + } + + private void handlePublishError(ErrorResponse error){ + wm.removeView(sendingOverlay); + sendingOverlay=null; + V.setVisibilityAnimated(sendProgress, View.GONE); + publishButton.setEnabled(true); + if(error instanceof MastodonErrorResponse me){ + new M3AlertDialogBuilder(getActivity()) + .setTitle(R.string.post_failed) + .setMessage(me.error) + .setPositiveButton(R.string.retry, (dlg, btn)->publish()) + .setNegativeButton(R.string.cancel, null) + .show(); + }else if(error!=null){ + error.showToast(getActivity()); } } + private void updateRecentLanguages() { + if (postLang == null || postLang.language == null) return; + String language = postLang.language.getLanguage(); + AccountLocalPreferences prefs = AccountSessionManager.get(accountID).getLocalPreferences(); + prefs.recentLanguages.remove(language); + prefs.recentLanguages.add(0, language); + if (postLang.encoding != null) { + prefs.recentLanguages.remove(postLang.encoding); + prefs.recentLanguages.add(0, postLang.encoding); + } + if ("bottom".equals(postLang.encoding) && !prefs.bottomEncoding) prefs.bottomEncoding = true; + prefs.save(); + } + private boolean hasDraft(){ if(getArguments().getBoolean("hasDraft", false)) return true; if(editingStatus!=null){ if(!mainEditText.getText().toString().equals(initialText)) return true; List existingMediaIDs=editingStatus.mediaAttachments.stream().map(a->a.id).collect(Collectors.toList()); - if(!existingMediaIDs.equals(attachments.stream().map(a->a.serverAttachment.id).collect(Collectors.toList()))) + if(!existingMediaIDs.equals(mediaViewController.getAttachmentIDs())) return true; if(!statusVisibility.equals(editingStatus.visibility)) return true; if(scheduledStatus != null && !scheduledStatus.scheduledAt.equals(scheduledAt)) return true; - return pollChanged; + if(sensitive != editingStatus.sensitive) return true; + return pollViewController.isPollChanged(); } - boolean pollFieldsHaveContent=false; - for(DraftPollOption opt:pollOptions) - pollFieldsHaveContent|=opt.edit.length()>0; - return (mainEditText.length()>0 && !mainEditText.getText().toString().equals(initialText)) || !attachments.isEmpty() || pollFieldsHaveContent; + boolean pollFieldsHaveContent=pollViewController.getNonEmptyOptionsCount()>0; + return (mainEditText.length()>0 && !mainEditText.getText().toString().equals(initialText)) || !mediaViewController.isEmpty() || pollFieldsHaveContent; } @Override @@ -1304,22 +1249,24 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr @Override public void onFragmentResult(int reqCode, boolean success, Bundle result){ if(reqCode==IMAGE_DESCRIPTION_RESULT && success){ - Attachment updated=Parcels.unwrap(result.getParcelable("attachment")); - for(DraftMediaAttachment att:attachments){ - if(att.serverAttachment.id.equals(updated.id)){ - att.serverAttachment=updated; - att.description=updated.description; - att.descriptionView.setText(att.description); - break; - } - } - } else if (reqCode == SCHEDULED_STATUS_OPENED_RESULT && success && getActivity() != null) { - Nav.finish(this); + String attID=result.getString("attachment"); + String text=result.getString("text"); + mediaViewController.setAltTextByID(attID, text); + }else if(reqCode==AUTOCOMPLETE_ACCOUNT_RESULT && success){ + Account acc=Parcels.unwrap(result.getParcelable("selectedAccount")); + if(currentAutocompleteSpan==null) + return; + Editable e=mainEditText.getText(); + int start=e.getSpanStart(currentAutocompleteSpan); + int end=e.getSpanEnd(currentAutocompleteSpan); + e.removeSpan(currentAutocompleteSpan); + e.replace(start, end, '@'+acc.acct+' '); + finishAutocomplete(); } } private void confirmDiscardDraftAndFinish(){ - boolean attachmentsPending = attachments.stream().anyMatch(att -> att.state != AttachmentUploadState.DONE); + boolean attachmentsPending = mediaViewController.areAnyAttachmentsNotDone(); if (attachmentsPending) new M3AlertDialogBuilder(getActivity()) .setTitle(R.string.sk_unfinished_attachments) .setMessage(R.string.sk_unfinished_attachments_message) @@ -1347,10 +1294,10 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr */ private void openFilePicker(boolean photoPicker){ Intent intent; - boolean usePhotoPicker=photoPicker && isPhotoPickerAvailable(); + boolean usePhotoPicker=photoPicker && UiUtils.isPhotoPickerAvailable(); if(usePhotoPicker){ intent=new Intent(MediaStore.ACTION_PICK_IMAGES); - intent.putExtra(MediaStore.EXTRA_PICK_IMAGES_MAX, MAX_ATTACHMENTS-getMediaAttachmentsCount()); + intent.putExtra(MediaStore.EXTRA_PICK_IMAGES_MAX, mediaViewController.getMaxAttachments()-mediaViewController.getMediaAttachmentsCount()); }else{ intent=new Intent(Intent.ACTION_GET_CONTENT); intent.addCategory(Intent.CATEGORY_OPENABLE); @@ -1378,480 +1325,55 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr if(requestCode==MEDIA_RESULT && resultCode==Activity.RESULT_OK){ Uri single=data.getData(); if(single!=null){ - addMediaAttachment(single, null); + mediaViewController.addMediaAttachment(single, null); }else{ ClipData clipData=data.getClipData(); for(int i=0;isizeLimit){ - float mb=sizeLimit/(float) (1024*1024); - String sMb=String.format(Locale.getDefault(), mb%1f==0f ? "%.0f" : "%.2f", mb); - showMediaAttachmentError(getString(R.string.media_attachment_too_big, UiUtils.getFileName(uri), sMb)); - return false; - } - } - } - pollBtn.setEnabled(false); - DraftMediaAttachment draft=new DraftMediaAttachment(); - draft.uri=uri; - draft.mimeType=type; - draft.description=description; - attachmentsView.addView(createMediaAttachmentView(draft)); - attachments.add(draft); - attachmentsView.setVisibility(View.VISIBLE); - draft.setOverlayVisible(true, false); - - if(!areThereAnyUploadingAttachments()){ - uploadNextQueuedAttachment(); - } - updatePublishButtonState(); - updateSensitive(); - if(getMediaAttachmentsCount()==MAX_ATTACHMENTS) - mediaBtn.setEnabled(false); - return true; - } - - private void showMediaAttachmentError(String text){ - if(!attachmentsErrorShowing){ - Toast.makeText(getActivity(), text, Toast.LENGTH_SHORT).show(); - attachmentsErrorShowing=true; - contentView.postDelayed(()->attachmentsErrorShowing=false, 2000); - } - } - - private View createMediaAttachmentView(DraftMediaAttachment draft){ - View thumb=getActivity().getLayoutInflater().inflate(R.layout.compose_media_thumb, attachmentsView, false); - ImageView img=thumb.findViewById(R.id.thumb); - if(draft.serverAttachment!=null){ - if(draft.serverAttachment.previewUrl!=null) - ViewImageLoader.load(img, draft.serverAttachment.blurhashPlaceholder, new UrlImageLoaderRequest(draft.serverAttachment.previewUrl, V.dp(250), V.dp(250))); - }else{ - if(draft.mimeType.startsWith("image/")){ - ViewImageLoader.load(img, null, new UrlImageLoaderRequest(draft.uri, V.dp(250), V.dp(250))); - }else if(draft.mimeType.startsWith("video/")){ - loadVideoThumbIntoView(img, draft.uri); - } - } - TextView fileName=thumb.findViewById(R.id.file_name); - fileName.setText(UiUtils.getFileName(draft.serverAttachment!=null ? Uri.parse(draft.serverAttachment.url) : draft.uri)); - - draft.view=thumb; - draft.imageView=img; - draft.progressBar=thumb.findViewById(R.id.progress); - draft.infoBar=thumb.findViewById(R.id.info_bar); - draft.overlay=thumb.findViewById(R.id.overlay); - draft.descriptionView=thumb.findViewById(R.id.description); - draft.uploadStateTitle=thumb.findViewById(R.id.state_title); - draft.uploadStateText=thumb.findViewById(R.id.state_text); - ImageButton btn=thumb.findViewById(R.id.remove_btn); - btn.setTag(draft); - btn.setOnClickListener(this::onRemoveMediaAttachmentClick); - btn=thumb.findViewById(R.id.remove_btn2); - btn.setTag(draft); - btn.setOnClickListener(this::onRemoveMediaAttachmentClick); - ImageButton retry=thumb.findViewById(R.id.retry_or_cancel_upload); - retry.setTag(draft); - retry.setOnClickListener(this::onRetryOrCancelMediaUploadClick); - draft.retryButton=retry; - draft.infoBar.setTag(draft); - draft.infoBar.setOnClickListener(this::onEditMediaDescriptionClick); - - if(!TextUtils.isEmpty(draft.description)) - draft.descriptionView.setText(draft.description); - - if(Build.VERSION.SDK_INT>=Build.VERSION_CODES.S){ - draft.overlay.setBackgroundColor(0xA6000000); - } - - if(draft.state==AttachmentUploadState.UPLOADING || draft.state==AttachmentUploadState.PROCESSING || draft.state==AttachmentUploadState.QUEUED){ - draft.progressBar.setVisibility(View.GONE); - }else if(draft.state==AttachmentUploadState.ERROR){ - draft.setOverlayVisible(true, false); - } - - return thumb; - } - - public void addFakeMediaAttachment(Uri uri, String description){ - pollBtn.setEnabled(false); - DraftMediaAttachment draft=new DraftMediaAttachment(); - draft.uri=uri; - draft.description=description; - attachmentsView.addView(createMediaAttachmentView(draft)); - attachments.add(draft); - attachmentsView.setVisibility(View.VISIBLE); - } - - private void uploadMediaAttachment(DraftMediaAttachment attachment){ - if(areThereAnyUploadingAttachments()){ - throw new IllegalStateException("there is already an attachment being uploaded"); - } - attachment.state=AttachmentUploadState.UPLOADING; - attachment.progressBar.setVisibility(View.VISIBLE); - ObjectAnimator rotationAnimator=ObjectAnimator.ofFloat(attachment.progressBar, View.ROTATION, 0f, 360f); - rotationAnimator.setInterpolator(new LinearInterpolator()); - rotationAnimator.setDuration(1500); - rotationAnimator.setRepeatCount(ObjectAnimator.INFINITE); - rotationAnimator.start(); - attachment.progressBarAnimator=rotationAnimator; - int maxSize=0; - String contentType=getActivity().getContentResolver().getType(attachment.uri); - if(contentType!=null && contentType.startsWith("image/")){ - maxSize=2_073_600; // TODO get this from instance configuration when it gets added there - } - attachment.uploadStateTitle.setText(""); - attachment.uploadStateText.setText(""); - attachment.progressBar.setProgress(0); - attachment.speedTracker.reset(); - attachment.speedTracker.addSample(0); - attachment.uploadRequest=(UploadAttachment) new UploadAttachment(attachment.uri, maxSize, attachment.description) - .setProgressListener(new ProgressListener(){ - @Override - public void onProgress(long transferred, long total){ - if(updateUploadEtaRunnable==null){ - // getting a NoSuchMethodError: No static method -$$Nest$mupdateUploadETAs(ComposeFragment;)V in class ComposeFragment - // when using method reference out of nowhere after changing code elsewhere. no idea. programming is awful, actually - // noinspection Convert2MethodRef - UiUtils.runOnUiThread(updateUploadEtaRunnable=()->ComposeFragment.this.updateUploadETAs(), 50); - } - int progress=Math.round(transferred/(float)total*attachment.progressBar.getMax()); - if(Build.VERSION.SDK_INT>=24) - attachment.progressBar.setProgress(progress, true); - else - attachment.progressBar.setProgress(progress); - - attachment.speedTracker.setTotalBytes(total); - attachment.uploadStateTitle.setText(getString(R.string.file_upload_progress, UiUtils.formatFileSize(getActivity(), transferred, true), UiUtils.formatFileSize(getActivity(), total, true))); - attachment.speedTracker.addSample(transferred); - } - }) - .setCallback(new Callback<>(){ - @Override - public void onSuccess(Attachment result){ - attachment.serverAttachment=result; - if(TextUtils.isEmpty(result.url)){ - attachment.state=AttachmentUploadState.PROCESSING; - attachment.processingPollingRunnable=()->pollForMediaAttachmentProcessing(attachment); - if(getActivity()==null) - return; - attachment.uploadStateTitle.setText(R.string.upload_processing); - attachment.uploadStateText.setText(""); - UiUtils.runOnUiThread(attachment.processingPollingRunnable, 1000); - if(!areThereAnyUploadingAttachments()) - uploadNextQueuedAttachment(); - }else{ - finishMediaAttachmentUpload(attachment); - } - } - - @Override - public void onError(ErrorResponse error){ - attachment.uploadRequest=null; - attachment.progressBarAnimator=null; - attachment.state=AttachmentUploadState.ERROR; - attachment.uploadStateTitle.setText(R.string.upload_failed); - if(error instanceof MastodonErrorResponse er){ - if(er.underlyingException instanceof SocketException || er.underlyingException instanceof UnknownHostException || er.underlyingException instanceof InterruptedIOException) - attachment.uploadStateText.setText(R.string.upload_error_connection_lost); - else - attachment.uploadStateText.setText(er.error); - }else{ - attachment.uploadStateText.setText(""); - } - attachment.retryButton.setImageResource(R.drawable.ic_fluent_arrow_clockwise_24_filled); - attachment.retryButton.setContentDescription(getString(R.string.retry_upload)); - - rotationAnimator.cancel(); - V.setVisibilityAnimated(attachment.retryButton, View.VISIBLE); - V.setVisibilityAnimated(attachment.progressBar, View.GONE); - - if(!areThereAnyUploadingAttachments()) - uploadNextQueuedAttachment(); - } - }) - .exec(accountID); - } - - private void onRemoveMediaAttachmentClick(View v){ - DraftMediaAttachment att=(DraftMediaAttachment) v.getTag(); - if(att.isUploadingOrProcessing()) - att.cancelUpload(); - attachments.remove(att); - if(!areThereAnyUploadingAttachments()) - uploadNextQueuedAttachment(); - attachmentsView.removeView(att.view); - if(getMediaAttachmentsCount()==0) - attachmentsView.setVisibility(View.GONE); - updatePublishButtonState(); - pollBtn.setEnabled(attachments.isEmpty()); - mediaBtn.setEnabled(true); - updateSensitive(); - } - - private void onRetryOrCancelMediaUploadClick(View v){ - DraftMediaAttachment att=(DraftMediaAttachment) v.getTag(); - if(att.state==AttachmentUploadState.ERROR){ - att.retryButton.setImageResource(R.drawable.ic_fluent_dismiss_24_filled); - att.retryButton.setContentDescription(getString(R.string.cancel)); - V.setVisibilityAnimated(att.progressBar, View.VISIBLE); - att.state=AttachmentUploadState.QUEUED; - if(!areThereAnyUploadingAttachments()){ - uploadNextQueuedAttachment(); - } - }else{ - onRemoveMediaAttachmentClick(v); - } - } - - private void pollForMediaAttachmentProcessing(DraftMediaAttachment attachment){ - attachment.processingPollingRequest=(GetAttachmentByID) new GetAttachmentByID(attachment.serverAttachment.id) - .setCallback(new Callback<>(){ - @Override - public void onSuccess(Attachment result){ - attachment.processingPollingRequest=null; - if(!TextUtils.isEmpty(result.url)){ - attachment.processingPollingRunnable=null; - attachment.serverAttachment=result; - finishMediaAttachmentUpload(attachment); - }else if(getActivity()!=null){ - UiUtils.runOnUiThread(attachment.processingPollingRunnable, 1000); - } - } - - @Override - public void onError(ErrorResponse error){ - attachment.processingPollingRequest=null; - if(getActivity()!=null) - UiUtils.runOnUiThread(attachment.processingPollingRunnable, 1000); - } - }) - .exec(accountID); - } - - private void finishMediaAttachmentUpload(DraftMediaAttachment attachment){ - if(attachment.state!=AttachmentUploadState.PROCESSING && attachment.state!=AttachmentUploadState.UPLOADING) - throw new IllegalStateException("Unexpected state "+attachment.state); - attachment.uploadRequest=null; - attachment.state=AttachmentUploadState.DONE; - attachment.progressBar.setVisibility(View.GONE); - if(!areThereAnyUploadingAttachments()) - uploadNextQueuedAttachment(); - updatePublishButtonState(); - - if(attachment.progressBarAnimator!=null){ - attachment.progressBarAnimator.cancel(); - attachment.progressBarAnimator=null; - } - attachment.setOverlayVisible(false, true); - } - - private void uploadNextQueuedAttachment(){ - for(DraftMediaAttachment att:attachments){ - if(att.state==AttachmentUploadState.QUEUED){ - uploadMediaAttachment(att); - return; - } - } - } - - private boolean areThereAnyUploadingAttachments(){ - for(DraftMediaAttachment att:attachments){ - if(att.state==AttachmentUploadState.UPLOADING) - return true; - } - return false; - } - - private void updateUploadETAs(){ - if(!areThereAnyUploadingAttachments()){ - UiUtils.removeCallbacks(updateUploadEtaRunnable); - updateUploadEtaRunnable=null; - return; - } - for(DraftMediaAttachment att:attachments){ - if(att.state==AttachmentUploadState.UPLOADING){ - long eta=att.speedTracker.updateAndGetETA(); -// Log.i(TAG, "onProgress: transfer speed "+UiUtils.formatFileSize(getActivity(), Math.round(att.speedTracker.getLastSpeed()), false)+" average "+UiUtils.formatFileSize(getActivity(), Math.round(att.speedTracker.getAverageSpeed()), false)+" eta "+eta); - String time=String.format("%d:%02d", eta/60, eta%60); - att.uploadStateText.setText(getString(R.string.file_upload_time_remaining, time)); - } - } - UiUtils.runOnUiThread(updateUploadEtaRunnable, 50); - } - - private void onEditMediaDescriptionClick(View v){ - DraftMediaAttachment att=(DraftMediaAttachment) v.getTag(); - if(att.serverAttachment==null) - return; - editMediaDescription(att); - } - - private void editMediaDescription(DraftMediaAttachment att) { - Bundle args=new Bundle(); - args.putString("account", accountID); - args.putString("attachment", att.serverAttachment.id); - args.putParcelable("uri", att.uri); - args.putString("existingDescription", att.description); - Nav.goForResult(getActivity(), ComposeImageDescriptionFragment.class, args, IMAGE_DESCRIPTION_RESULT, this); + public void updateMediaPollStates(){ + pollBtn.setSelected(pollViewController.isShown()); + mediaBtn.setEnabled(!pollViewController.isShown() && mediaViewController.canAddMoreAttachments()); + pollBtn.setEnabled(mediaViewController.isEmpty()); } private void togglePoll(){ - if(pollOptions.isEmpty()){ - pollBtn.setSelected(true); - mediaBtn.setEnabled(false); - pollWrap.setVisibility(View.VISIBLE); - for(int i=0;i<2;i++) - createDraftPollOption(); - updatePollOptionHints(); - }else{ - pollBtn.setSelected(false); - mediaBtn.setEnabled(true); - pollWrap.setVisibility(View.GONE); - addPollOptionBtn.setVisibility(View.VISIBLE); - pollOptionsView.removeAllViews(); - pollOptions.clear(); - pollDuration=24*3600; - } + pollViewController.toggle(); updatePublishButtonState(); - } - - private DraftPollOption createDraftPollOption(){ - DraftPollOption option=new DraftPollOption(); - option.view=LayoutInflater.from(getActivity()).inflate(R.layout.compose_poll_option, pollOptionsView, false); - option.edit=option.view.findViewById(R.id.edit); - option.dragger=option.view.findViewById(R.id.dragger_thingy); - ImageView icon = option.view.findViewById(R.id.icon); - icon.setImageDrawable(getContext().getDrawable(pollAllowMultipleItem.isSelected() ? - R.drawable.ic_poll_checkbox_regular_selector : - R.drawable.ic_poll_option_button - )); - - option.dragger.setOnLongClickListener(v->{ - pollOptionsView.startDragging(option.view); - return true; - }); - option.edit.addTextChangedListener(new SimpleTextWatcher(e->{ - if(!creatingView) - pollChanged=true; - updatePublishButtonState(); - })); - - int maxCharactersPerOption = 50; - if(instance.configuration!=null && instance.configuration.polls!=null && instance.configuration.polls.maxCharactersPerOption>0) - maxCharactersPerOption = instance.configuration.polls.maxCharactersPerOption; - else if(instance.pollLimits!=null && instance.pollLimits.maxOptionChars>0) - maxCharactersPerOption = instance.pollLimits.maxOptionChars; - option.edit.setFilters(new InputFilter[]{new InputFilter.LengthFilter(maxCharactersPerOption)}); - - pollOptionsView.addView(option.view); - pollOptions.add(option); - - int maxPollOptions = 4; - if(instance.configuration!=null && instance.configuration.polls!=null && instance.configuration.polls.maxOptions>0) - maxPollOptions = instance.configuration.polls.maxOptions; - else if (instance.pollLimits!=null && instance.pollLimits.maxOptions>0) - maxPollOptions = instance.pollLimits.maxOptions; - - if(pollOptions.size()==maxPollOptions) - addPollOptionBtn.setVisibility(View.GONE); - return option; - } - - private void updatePollOptionHints(){ - int i=0; - for(DraftPollOption option:pollOptions){ - option.edit.setHint(getString(R.string.poll_option_hint, ++i)); - } - } - - private void onSwapPollOptions(int oldIndex, int newIndex){ - pollOptions.add(newIndex, pollOptions.remove(oldIndex)); - updatePollOptionHints(); - pollChanged=true; - } - - private void showPollDurationMenu(){ - PopupMenu menu=new PopupMenu(getActivity(), pollDurationView); - menu.getMenu().add(0, 1, 0, getResources().getQuantityString(R.plurals.x_minutes, 5, 5)); - menu.getMenu().add(0, 2, 0, getResources().getQuantityString(R.plurals.x_minutes, 30, 30)); - menu.getMenu().add(0, 3, 0, getResources().getQuantityString(R.plurals.x_hours, 1, 1)); - menu.getMenu().add(0, 4, 0, getResources().getQuantityString(R.plurals.x_hours, 6, 6)); - menu.getMenu().add(0, 5, 0, getResources().getQuantityString(R.plurals.x_hours, 12, 12)); - menu.getMenu().add(0, 6, 0, getResources().getQuantityString(R.plurals.x_days, 1, 1)); - menu.getMenu().add(0, 7, 0, getResources().getQuantityString(R.plurals.x_days, 3, 3)); - menu.getMenu().add(0, 8, 0, getResources().getQuantityString(R.plurals.x_days, 7, 7)); - menu.setOnMenuItemClickListener(item->{ - pollDuration=switch(item.getItemId()){ - case 1 -> 5*60; - case 2 -> 30*60; - case 3 -> 3600; - case 4 -> 6*3600; - case 5 -> 12*3600; - case 6 -> 24*3600; - case 7 -> 3*24*3600; - case 8 -> 7*24*3600; - default -> throw new IllegalStateException("Unexpected value: "+item.getItemId()); - }; - pollDurationView.setText(getString(R.string.compose_poll_duration, pollDurationStr=item.getTitle().toString())); - pollChanged=true; - return true; - }); - menu.show(); + updateMediaPollStates(); } private void toggleSpoiler(){ hasSpoiler=!hasSpoiler; if(hasSpoiler){ - spoilerEdit.setVisibility(View.VISIBLE); + spoilerWrap.setVisibility(View.VISIBLE); spoilerBtn.setSelected(true); spoilerEdit.requestFocus(); }else{ - spoilerEdit.setVisibility(View.GONE); + spoilerWrap.setVisibility(View.GONE); spoilerEdit.setText(""); spoilerBtn.setSelected(false); mainEditText.requestFocus(); updateCharCounter(); - sensitiveIcon.setVisibility(getMediaAttachmentsCount() > 0 ? View.VISIBLE : View.GONE); + sensitiveBtn.setVisibility(mediaViewController.getMediaAttachmentsCount() > 0 ? View.VISIBLE : View.GONE); } updateSensitive(); } private void toggleSensitive() { sensitive=!sensitive; - sensitiveIcon.setSelected(sensitive); + sensitiveBtn.setSelected(sensitive); } - private void updateSensitive() { - sensitiveItem.setVisibility(View.GONE); - if (!attachments.isEmpty() && !hasSpoiler) sensitiveItem.setVisibility(View.VISIBLE); - if (attachments.isEmpty()) sensitive = false; + public void updateSensitive() { + sensitiveBtn.setVisibility(View.GONE); + if (!mediaViewController.isEmpty() && !hasSpoiler) sensitiveBtn.setVisibility(View.VISIBLE); + if (mediaViewController.isEmpty()) sensitive = false; } private void pickScheduledDateTime() { @@ -1870,7 +1392,7 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr private void updateScheduledAt(Instant scheduledAt) { this.scheduledAt = scheduledAt; updatePublishButtonState(); - scheduleDraftView.setVisibility(scheduledAt == null ? View.GONE : View.VISIBLE); + V.setVisibilityAnimated(scheduleDraftView, scheduledAt == null ? View.GONE : View.VISIBLE); draftMenuItem.setVisible(true); scheduleMenuItem.setVisible(true); undraftMenuItem.setVisible(false); @@ -1883,8 +1405,11 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr scheduleTimeBtn.setVisibility(View.GONE); scheduleDraftText.setText(R.string.sk_compose_draft); scheduleDraftText.setCompoundDrawablesWithIntrinsicBounds(R.drawable.ic_fluent_drafts_20_regular, 0, 0, 0); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + scheduleDraftDismiss.setTooltipText(getString(R.string.sk_compose_no_draft)); + } scheduleDraftDismiss.setContentDescription(getString(R.string.sk_compose_no_draft)); - draftsBtn.setCompoundDrawablesWithIntrinsicBounds(R.drawable.ic_fluent_drafts_20_filled, 0, 0, 0); + draftsBtn.setImageResource(R.drawable.ic_fluent_drafts_20_filled); publishButton.setText(scheduledStatus != null && scheduledStatus.scheduledAt.isAfter(DRAFTS_AFTER_INSTANT) ? R.string.save : R.string.sk_draft); } else { @@ -1895,51 +1420,51 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr scheduleTimeBtn.setText(at); scheduleDraftText.setText(R.string.sk_compose_scheduled); scheduleDraftText.setCompoundDrawablesWithIntrinsicBounds(0, 0, 0, 0); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + scheduleDraftDismiss.setTooltipText(getString(R.string.sk_compose_no_schedule)); + } scheduleDraftDismiss.setContentDescription(getString(R.string.sk_compose_no_schedule)); - draftsBtn.setCompoundDrawablesWithIntrinsicBounds(R.drawable.ic_fluent_clock_20_filled, 0, 0, 0); + draftsBtn.setImageResource(R.drawable.ic_fluent_clock_20_filled); publishButton.setText(scheduledStatus != null && scheduledStatus.scheduledAt.equals(scheduledAt) ? R.string.save : R.string.sk_schedule); } } else { - draftsBtn.setCompoundDrawablesWithIntrinsicBounds(R.drawable.ic_fluent_clock_20_regular, 0, 0, 0); + draftsBtn.setImageResource(R.drawable.ic_fluent_clock_20_regular); resetPublishButtonText(); } } - private int getMediaAttachmentsCount(){ - return attachments.size(); - } - private void updateHeaders() { - UiUtils.setExtraTextInfo(getContext(), selfExtraText, statusVisibility, localOnly); - if (replyTo != null) UiUtils.setExtraTextInfo(getContext(), extraText, replyTo.visibility, replyTo.localOnly); + UiUtils.setExtraTextInfo(getContext(), selfExtraText, null, false, localOnly || statusVisibility==StatusPrivacy.LOCAL, null); + if (replyTo != null) UiUtils.setExtraTextInfo(getContext(), extraText, pronouns, false, replyTo.localOnly || replyTo.visibility==StatusPrivacy.LOCAL, replyTo.account); } private void buildVisibilityPopup(View v){ visibilityPopup=new PopupMenu(getActivity(), v); visibilityPopup.inflate(R.menu.compose_visibility); Menu m=visibilityPopup.getMenu(); - if (isInstancePixelfed()) { + if(isInstancePixelfed()){ m.findItem(R.id.vis_private).setVisible(false); } - MenuItem localOnlyItem = visibilityPopup.getMenu().findItem(R.id.local_only); - boolean prefsSaysSupported = GlobalUserPreferences.accountsWithLocalOnlySupport.contains(accountID); - if (isInstanceAkkoma()) { + MenuItem localOnlyItem=visibilityPopup.getMenu().findItem(R.id.local_only); + AccountLocalPreferences prefs=AccountSessionManager.get(accountID).getLocalPreferences(); + boolean prefsSaysSupported=prefs.localOnlySupported; + if(isInstanceAkkoma()){ m.findItem(R.id.vis_local).setVisible(true); - } else if (localOnly || prefsSaysSupported) { + }else if(localOnly || prefsSaysSupported){ localOnlyItem.setVisible(true); localOnlyItem.setChecked(localOnly); - Status status = editingStatus != null ? editingStatus : replyTo; - if (!prefsSaysSupported) { - GlobalUserPreferences.accountsWithLocalOnlySupport.add(accountID); - if (GLITCH_LOCAL_ONLY_PATTERN.matcher(status.getStrippedText()).matches()) { - GlobalUserPreferences.accountsInGlitchMode.add(accountID); + Status status=editingStatus!=null ? editingStatus : replyTo; + if(!prefsSaysSupported){ + prefs.localOnlySupported=true; + if(GLITCH_LOCAL_ONLY_PATTERN.matcher(status.getStrippedText()).matches()){ + prefs.glitchInstance=true; } - GlobalUserPreferences.save(); + prefs.save(); } } UiUtils.enablePopupMenuIcons(getActivity(), visibilityPopup); - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) m.setGroupDividerEnabled(true); + if(Build.VERSION.SDK_INT>=Build.VERSION_CODES.P) m.setGroupDividerEnabled(true); visibilityPopup.setOnMenuItemClickListener(new PopupMenu.OnMenuItemClickListener(){ @Override public boolean onMenuItemClick(MenuItem item){ @@ -1955,10 +1480,10 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr }else if(id==R.id.vis_local){ statusVisibility=StatusPrivacy.LOCAL; } - if (id == R.id.local_only) { - localOnly = !item.isChecked(); + if(id==R.id.local_only){ + localOnly=!item.isChecked(); item.setChecked(localOnly); - } else { + }else{ item.setChecked(true); } updateVisibilityIcon(); @@ -1971,35 +1496,54 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr @SuppressLint("ClickableViewAccessibility") private void buildContentTypePopup(View btn) { contentTypePopup=new PopupMenu(getActivity(), btn); - contentTypePopup.inflate(R.menu.compose_content_type); Menu m = contentTypePopup.getMenu(); - ContentType.adaptMenuToInstance(m, instance); - if (contentType != null) m.findItem(R.id.content_type_null).setVisible(false); + for(ContentType value : ContentType.values()){ + if(!value.supportedByInstance(instance)) continue; + m.add(0, value.ordinal(), Menu.NONE, value.getName()); + } + m.setGroupCheckable(0, true, true); + if (contentType!=ContentType.UNSPECIFIED || editingStatus!=null){ + // setting content type to null while editing will just leave it unchanged + m.findItem(ContentType.UNSPECIFIED.ordinal()).setVisible(false); + } contentTypePopup.setOnMenuItemClickListener(i->{ - int id=i.getItemId(); - if (id == R.id.content_type_null) contentType = null; - else if (id == R.id.content_type_plain) contentType = ContentType.PLAIN; - else if (id == R.id.content_type_html) contentType = ContentType.HTML; - else if (id == R.id.content_type_markdown) contentType = ContentType.MARKDOWN; - else if (id == R.id.content_type_bbcode) contentType = ContentType.BBCODE; - else if (id == R.id.content_type_misskey_markdown) contentType = ContentType.MISSKEY_MARKDOWN; - else return false; - btn.setSelected(id != R.id.content_type_null && id != R.id.content_type_plain); + int index=i.getItemId(); + contentType=ContentType.values()[index]; + btn.setSelected(index!=ContentType.UNSPECIFIED.ordinal() && index!=ContentType.PLAIN.ordinal()); i.setChecked(true); return true; }); - if (!GlobalUserPreferences.accountsWithContentTypesEnabled.contains(accountID)) { + if (!AccountSessionManager.get(accountID).getLocalPreferences().contentTypesEnabled) { btn.setVisibility(View.GONE); } } - private void loadDefaultStatusVisibility(Bundle savedInstanceState) { + private void onVisibilityClick(View v){ + PopupMenu menu=new PopupMenu(getActivity(), v); + menu.inflate(R.menu.compose_visibility); + menu.setOnMenuItemClickListener(item->{ + int id=item.getItemId(); + if(id==R.id.vis_public){ + statusVisibility=StatusPrivacy.PUBLIC; + }else if(id==R.id.vis_followers){ + statusVisibility=StatusPrivacy.PRIVATE; + }else if(id==R.id.vis_private){ + statusVisibility=StatusPrivacy.DIRECT; + } + item.setChecked(true); + updateVisibilityIcon(); + return true; + }); + menu.show(); + } + + private void loadDefaultStatusVisibility(Bundle savedInstanceState){ if(replyTo != null) statusVisibility = replyTo.visibility; AccountSessionManager asm = AccountSessionManager.getInstance(); - Preferences prefs = asm.getAccount(accountID).preferences; + Preferences prefs=asm.getAccount(accountID).preferences; if (prefs != null) { // Only override the reply visibility if our preference is more private // (and we're not replying to ourselves, or not at all) @@ -2008,45 +1552,31 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr statusVisibility = prefs.postingDefaultVisibility; } } - - // A saved privacy setting from a previous compose session wins over all - if(savedInstanceState !=null){ - statusVisibility = (StatusPrivacy) savedInstanceState.getSerializable("visibility"); - } } private void updateVisibilityIcon(){ + if(getActivity()==null) + return; if(statusVisibility==null){ // TODO find out why this happens statusVisibility=StatusPrivacy.PUBLIC; } - visibilityBtn.setImageResource(switch(statusVisibility){ - case PUBLIC -> R.drawable.ic_fluent_earth_24_regular; - case UNLISTED -> R.drawable.ic_fluent_lock_open_24_regular; - case PRIVATE -> R.drawable.ic_fluent_lock_closed_24_filled; - case DIRECT -> R.drawable.ic_fluent_mention_24_regular; - case LOCAL -> R.drawable.ic_fluent_eye_24_regular; + visibilityBtn.setText(switch(statusVisibility){ + case PUBLIC -> R.string.visibility_public; + case UNLISTED -> R.string.sk_visibility_unlisted; + case PRIVATE -> R.string.visibility_followers_only; + case DIRECT -> R.string.visibility_private; + case LOCAL -> R.string.sk_local_only; }); - } - - private void togglePollAllowMultiple() { - updatePollAllowMultiple(!pollAllowMultipleItem.isSelected()); - } - - private void updatePollAllowMultiple(boolean multiple){ - pollAllowMultipleItem.setSelected(multiple); - pollAllowMultipleCheckbox.setChecked(multiple); - ImageView btn = addPollOptionBtn.findViewById(R.id.add_poll_option_icon); - btn.setImageDrawable(getContext().getDrawable(multiple ? - R.drawable.ic_fluent_add_square_24_regular : - R.drawable.ic_fluent_add_circle_24_regular - )); - for (DraftPollOption opt:pollOptions) { - ImageView icon = opt.view.findViewById(R.id.icon); - icon.setImageDrawable(getContext().getDrawable(multiple ? - R.drawable.ic_poll_checkbox_regular_selector : - R.drawable.ic_poll_option_button - )); - } + Drawable icon=getResources().getDrawable(switch(statusVisibility){ + case PUBLIC -> R.drawable.ic_fluent_earth_16_regular; + case UNLISTED -> R.drawable.ic_fluent_lock_open_16_regular; + case PRIVATE -> R.drawable.ic_fluent_lock_closed_16_filled; + case DIRECT -> R.drawable.ic_fluent_mention_16_regular; + case LOCAL -> R.drawable.ic_fluent_eye_16_regular; + }, getActivity().getTheme()).mutate(); + icon.setBounds(0, 0, V.dp(18), V.dp(18)); + visibilityBtn.setCompoundDrawableTintList(getContext().getResources().getColorStateList(R.color.m3_primary_selector, getContext().getTheme())); + visibilityBtn.setCompoundDrawablesRelative(icon, null, visibilityBtn.getCompoundDrawablesRelative()[2], null); } @Override @@ -2065,18 +1595,6 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr String spanText=e.toString().substring(e.getSpanStart(span), e.getSpanEnd(span)); autocompleteViewController.setText(spanText); } - - View autocompleteView=autocompleteViewController.getView(); - Layout layout=mainEditText.getLayout(); - int line=layout.getLineForOffset(start); - int offsetY=layout.getLineBottom(line); - FrameLayout.LayoutParams lp=(FrameLayout.LayoutParams) autocompleteView.getLayoutParams(); - if(lp.topMargin!=offsetY){ - lp.topMargin=offsetY; - mainEditTextWrap.requestLayout(); - } - int offsetX=Math.round(layout.getPrimaryHorizontal(start))+mainEditText.getPaddingLeft(); - autocompleteViewController.setArrowOffset(offsetX); }else if(currentAutocompleteSpan!=null){ finishAutocomplete(); } @@ -2094,7 +1612,7 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr @Override public boolean onAddMediaAttachmentFromEditText(Uri uri, String description){ - return addMediaAttachment(uri, description); + return mediaViewController.addMediaAttachment(uri, description); } private void startAutocomplete(ComposeAutocompleteSpan span){ @@ -2102,8 +1620,7 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr Editable e=mainEditText.getText(); String spanText=e.toString().substring(e.getSpanStart(span), e.getSpanEnd(span)); autocompleteViewController.setText(spanText); - View autocompleteView=autocompleteViewController.getView(); - autocompleteView.setVisibility(View.VISIBLE); + showAutocomplete(); } private void finishAutocomplete(){ @@ -2111,7 +1628,26 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr return; autocompleteViewController.setText(null); currentAutocompleteSpan=null; - autocompleteViewController.getView().setVisibility(View.GONE); + hideAutocomplete(); + } + + private void showAutocomplete(){ + UiUtils.beginLayoutTransition(bottomBar); + UiUtils.beginLayoutTransition(scheduleDraftView); + View autocompleteView=autocompleteViewController.getView(); + bottomBar.getLayoutParams().height=ViewGroup.LayoutParams.WRAP_CONTENT; + bottomBar.requestLayout(); + autocompleteView.setVisibility(View.VISIBLE); + autocompleteDivider.setVisibility(View.VISIBLE); + } + + private void hideAutocomplete(){ + UiUtils.beginLayoutTransition(bottomBar); + UiUtils.beginLayoutTransition(scheduleDraftView); + bottomBar.getLayoutParams().height=V.dp(56); + bottomBar.requestLayout(); + autocompleteViewController.getView().setVisibility(View.INVISIBLE); + autocompleteDivider.setVisibility(View.INVISIBLE); } private void onAutocompleteOptionSelected(String text){ @@ -2119,32 +1655,10 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr int start=e.getSpanStart(currentAutocompleteSpan); int end=e.getSpanEnd(currentAutocompleteSpan); e.replace(start, end, text+" "); - mainEditText.setSelection(start+text.length()+1); finishAutocomplete(); - } - - private void loadVideoThumbIntoView(ImageView target, Uri uri){ - MastodonAPIController.runInBackground(()->{ - Context context=getActivity(); - if(context==null) - return; - try{ - MediaMetadataRetriever mmr=new MediaMetadataRetriever(); - mmr.setDataSource(context, uri); - Bitmap frame=mmr.getFrameAtTime(3_000_000); - mmr.release(); - int size=Math.max(frame.getWidth(), frame.getHeight()); - int maxSize=V.dp(250); - if(size>maxSize){ - float factor=maxSize/(float)size; - frame=Bitmap.createScaledBitmap(frame, Math.round(frame.getWidth()*factor), Math.round(frame.getHeight()*factor), true); - } - Bitmap finalFrame=frame; - target.post(()->target.setImageBitmap(finalFrame)); - }catch(Exception x){ - Log.w(TAG, "loadVideoThumbIntoView: error getting video frame", x); - } - }); + InputConnection conn=mainEditText.getCurrentInputConnection(); + if(conn!=null) + conn.finishComposingText(); } @Override @@ -2162,85 +1676,50 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr return !UiUtils.isDarkTheme(); } - @Parcel - static class DraftMediaAttachment{ - public Attachment serverAttachment; - public Uri uri; - public transient UploadAttachment uploadRequest; - public transient GetAttachmentByID processingPollingRequest; - public String description; - public String mimeType; - public AttachmentUploadState state=AttachmentUploadState.QUEUED; - - public transient View view; - public transient ProgressBar progressBar; - public transient TextView descriptionView; - public transient View overlay; - public transient View infoBar; - public transient ImageButton retryButton; - public transient ObjectAnimator progressBarAnimator; - public transient Runnable processingPollingRunnable; - public transient ImageView imageView; - public transient TextView uploadStateTitle, uploadStateText; - public transient TransferSpeedTracker speedTracker=new TransferSpeedTracker(); - - public void cancelUpload(){ - switch(state){ - case UPLOADING -> { - if(uploadRequest!=null){ - uploadRequest.cancel(); - uploadRequest=null; - } - } - case PROCESSING -> { - if(processingPollingRunnable!=null){ - UiUtils.removeCallbacks(processingPollingRunnable); - processingPollingRunnable=null; - } - if(processingPollingRequest!=null){ - processingPollingRequest.cancel(); - processingPollingRequest=null; - } - } - default -> throw new IllegalStateException("Unexpected state "+state); - } - } - - public boolean isUploadingOrProcessing(){ - return state==AttachmentUploadState.UPLOADING || state==AttachmentUploadState.PROCESSING; - } - - public void setOverlayVisible(boolean visible, boolean animated){ - if(Build.VERSION.SDK_INT>=Build.VERSION_CODES.S){ - if(visible){ - imageView.setRenderEffect(RenderEffect.createBlurEffect(V.dp(16), V.dp(16), Shader.TileMode.REPEAT)); - }else{ - imageView.setRenderEffect(null); - } - } - int infoBarVis=visible ? View.GONE : View.VISIBLE; - int overlayVis=visible ? View.VISIBLE : View.GONE; - if(animated){ - V.setVisibilityAnimated(infoBar, infoBarVis); - V.setVisibilityAnimated(overlay, overlayVis); - }else{ - infoBar.setVisibility(infoBarVis); - overlay.setVisibility(overlayVis); - } - } + public boolean getWasDetached(){ + return wasDetached; } - enum AttachmentUploadState{ - QUEUED, - UPLOADING, - PROCESSING, - ERROR, - DONE + public boolean isCreatingView(){ + return creatingView; } - private static class DraftPollOption{ - public EditText edit; - public View view; - public View dragger; + @Override + public String getAccountID(){ + return accountID; + } + + public void addFakeMediaAttachment(Uri uri, String description){ + mediaViewController.addFakeMediaAttachment(uri, description); + } + + private void showLanguageAlert(){ + AccountSession session=AccountSessionManager.get(accountID); + ComposeLanguageAlertViewController vc=new ComposeLanguageAlertViewController(getActivity(), session.preferences!=null ? session.preferences.postingDefaultLanguage : null, postLang, mainEditText.getText().toString(), languageResolver, session); + new M3AlertDialogBuilder(getActivity()) + .setTitle(R.string.language) + .setView(vc.getView()) + .setPositiveButton(R.string.ok, (dialog, which)->setPostLanguage(vc.getSelectedOption())) + .setNegativeButton(R.string.cancel, null) + .show(); + } + + private void setPostLanguage(String lang) { + setPostLanguage(lang == null ? languageResolver.getDefault() : languageResolver.fromOrFallback(lang)); + } + + private void setPostLanguage(MastodonLanguage lang) { + setPostLanguage(new ComposeLanguageAlertViewController.SelectedOption(lang)); + } + + private void setPostLanguage(ComposeLanguageAlertViewController.SelectedOption opt){ + postLang=opt; + if (Objects.equals("bottom", opt.encoding)) { + languageButton.setText("\uD83E\uDD7A\uD83D\uDC49\uD83D\uDC48"); + languageButton.setContentDescription(opt.encoding); + return; + } + languageButton.setText(opt.language.getLanguageName()); + languageButton.setContentDescription(getActivity().getString(R.string.sk_post_language, opt.language.getDefaultName())); } } diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/ComposeImageDescriptionFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/ComposeImageDescriptionFragment.java index 02bea5f6d..90fdf6fcb 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/ComposeImageDescriptionFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/ComposeImageDescriptionFragment.java @@ -1,10 +1,18 @@ package org.joinmastodon.android.fragments; import android.app.Activity; -import android.content.res.TypedArray; +import android.content.Context; +import android.graphics.Bitmap; +import android.graphics.Rect; +import android.graphics.drawable.Drawable; +import android.media.MediaMetadataRetriever; import android.net.Uri; +import android.os.Build; import android.os.Bundle; -import android.view.Gravity; +import android.text.SpannableStringBuilder; +import android.text.style.BulletSpan; +import android.util.Log; +import android.view.ContextThemeWrapper; import android.view.LayoutInflater; import android.view.Menu; import android.view.MenuInflater; @@ -12,28 +20,34 @@ import android.view.MenuItem; import android.view.View; import android.view.ViewGroup; import android.view.inputmethod.InputMethodManager; -import android.widget.Button; import android.widget.EditText; -import android.widget.FrameLayout; import android.widget.ImageView; import org.joinmastodon.android.R; -import org.joinmastodon.android.api.requests.statuses.UpdateAttachment; +import org.joinmastodon.android.api.MastodonAPIController; import org.joinmastodon.android.model.Attachment; -import org.parceler.Parcels; +import org.joinmastodon.android.ui.M3AlertDialogBuilder; +import org.joinmastodon.android.ui.photoviewer.PhotoViewer; +import org.joinmastodon.android.ui.utils.UiUtils; +import org.joinmastodon.android.ui.views.FixedAspectRatioImageView; -import me.grishka.appkit.Nav; -import me.grishka.appkit.api.Callback; -import me.grishka.appkit.api.ErrorResponse; -import me.grishka.appkit.fragments.ToolbarFragment; +import java.util.Collections; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import me.grishka.appkit.fragments.OnBackPressedListener; import me.grishka.appkit.imageloader.ViewImageLoader; import me.grishka.appkit.imageloader.requests.UrlImageLoaderRequest; import me.grishka.appkit.utils.V; -public class ComposeImageDescriptionFragment extends MastodonToolbarFragment{ +public class ComposeImageDescriptionFragment extends MastodonToolbarFragment implements OnBackPressedListener{ + private static final String TAG="ComposeImageDescription"; + private String accountID, attachmentID; private EditText edit; - private Button saveButton; + private ImageView image; + private ContextThemeWrapper themeWrapper; + private PhotoViewer photoViewer; @Override public void onCreate(Bundle savedInstanceState){ @@ -46,7 +60,13 @@ public class ComposeImageDescriptionFragment extends MastodonToolbarFragment{ @Override public void onAttach(Activity activity){ super.onAttach(activity); - setTitle(R.string.edit_image); + themeWrapper=new ContextThemeWrapper(activity, R.style.Theme_Mastodon_Dark); + setTitle(R.string.add_alt_text); + } + + @Override + public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState){ + return super.onCreateView(themeWrapper.getSystemService(LayoutInflater.class), container, savedInstanceState); } @Override @@ -54,14 +74,48 @@ public class ComposeImageDescriptionFragment extends MastodonToolbarFragment{ View view=inflater.inflate(R.layout.fragment_image_description, container, false); edit=view.findViewById(R.id.edit); - ImageView image=view.findViewById(R.id.photo); + image=view.findViewById(R.id.photo); + int width=getArguments().getInt("width", 0); + int height=getArguments().getInt("height", 0); + if(width>0 && height>0){ + // image.setAspectRatio(Math.max(1f, (float)width/height)); + } + image.setOnClickListener(v->openPhotoViewer()); Uri uri=getArguments().getParcelable("uri"); - ViewImageLoader.load(image, null, new UrlImageLoaderRequest(uri, 1000, 1000)); + Attachment.Type type=Attachment.Type.valueOf(getArguments().getString("attachmentType")); + if(type==Attachment.Type.IMAGE) + ViewImageLoader.load(image, null, new UrlImageLoaderRequest(uri, 1000, 1000)); + else + loadVideoThumbIntoView(image, uri); edit.setText(getArguments().getString("existingDescription")); return view; } + private void loadVideoThumbIntoView(ImageView target, Uri uri){ + MastodonAPIController.runInBackground(()->{ + Context context=getActivity(); + if(context==null) + return; + try{ + MediaMetadataRetriever mmr=new MediaMetadataRetriever(); + mmr.setDataSource(context, uri); + Bitmap frame=mmr.getFrameAtTime(3_000_000); + mmr.release(); + int size=Math.max(frame.getWidth(), frame.getHeight()); + int maxSize=V.dp(250); + if(size>maxSize){ + float factor=maxSize/(float)size; + frame=Bitmap.createScaledBitmap(frame, Math.round(frame.getWidth()*factor), Math.round(frame.getHeight()*factor), true); + } + Bitmap finalFrame=frame; + target.post(()->target.setImageBitmap(finalFrame)); + }catch(Exception x){ + Log.w(TAG, "loadVideoThumbIntoView: error getting video frame", x); + } + }); + } + @Override public void onViewCreated(View view, Bundle savedInstanceState){ super.onViewCreated(view, savedInstanceState); @@ -71,43 +125,114 @@ public class ComposeImageDescriptionFragment extends MastodonToolbarFragment{ @Override public void onCreateOptionsMenu(Menu menu, MenuInflater inflater){ - TypedArray ta=getActivity().obtainStyledAttributes(new int[]{R.attr.secondaryButtonStyle}); - int buttonStyle=ta.getResourceId(0, 0); - ta.recycle(); - saveButton=new Button(getActivity(), null, 0, buttonStyle); - saveButton.setText(R.string.save); - saveButton.setOnClickListener(this::onSaveClick); - FrameLayout wrap=new FrameLayout(getActivity()); - wrap.addView(saveButton, new FrameLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT, Gravity.TOP|Gravity.LEFT)); - wrap.setPadding(V.dp(16), V.dp(4), V.dp(16), V.dp(8)); - wrap.setClipToPadding(false); - MenuItem item=menu.add(R.string.publish); - item.setActionView(wrap); - item.setShowAsAction(MenuItem.SHOW_AS_ACTION_ALWAYS); + inflater.inflate(R.menu.compose_image_description, menu); } @Override public boolean onOptionsItemSelected(MenuItem item){ + if(item.getItemId()==R.id.help){ + SpannableStringBuilder msg=new SpannableStringBuilder(getText(R.string.alt_text_help)); + BulletSpan[] spans=msg.getSpans(0, msg.length(), BulletSpan.class); + for(BulletSpan span:spans){ + BulletSpan betterSpan; + if(Build.VERSION.SDK_INT(){ - @Override - public void onSuccess(Attachment result){ - Bundle r=new Bundle(); - r.putParcelable("attachment", Parcels.wrap(result)); - setResult(true, r); - Nav.finish(ComposeImageDescriptionFragment.this); - } + @Override + public boolean onBackPressed(){ + deliverResult(); + return false; + } - @Override - public void onError(ErrorResponse error){ - error.showToast(getActivity()); - } - }) - .wrapProgress(getActivity(), R.string.saving, false) - .exec(accountID); + @Override + protected LayoutInflater getToolbarLayoutInflater(){ + return LayoutInflater.from(themeWrapper); + } + + private void deliverResult(){ + Bundle r=new Bundle(); + r.putString("text", edit.getText().toString().trim()); + r.putString("attachment", attachmentID); + setResult(true, r); + } + + private void openPhotoViewer(){ + Attachment fakeAttachment=new Attachment(); + fakeAttachment.id="local"; + fakeAttachment.type=Attachment.Type.valueOf(getArguments().getString("attachmentType")); + int width=getArguments().getInt("width", 0); + int height=getArguments().getInt("height", 0); + Uri uri=getArguments().getParcelable("uri"); + fakeAttachment.url=uri.toString(); + fakeAttachment.meta=new Attachment.Metadata(); + fakeAttachment.meta.width=width; + fakeAttachment.meta.height=height; + + photoViewer=new PhotoViewer(getActivity(), Collections.singletonList(fakeAttachment), 0, new PhotoViewer.Listener(){ + @Override + public void setPhotoViewVisibility(int index, boolean visible){ + image.setAlpha(visible ? 1f : 0f); + } + + @Override + public boolean startPhotoViewTransition(int index, @NonNull Rect outRect, @NonNull int[] outCornerRadius){ + int[] pos={0, 0}; + image.getLocationOnScreen(pos); + outRect.set(pos[0], pos[1], pos[0]+image.getWidth(), pos[1]+image.getHeight()); + image.setElevation(1f); + return true; + } + + @Override + public void setTransitioningViewTransform(float translateX, float translateY, float scale){ + image.setTranslationX(translateX); + image.setTranslationY(translateY); + image.setScaleX(scale); + image.setScaleY(scale); + } + + @Override + public void endPhotoViewTransition(){ + Drawable d=image.getDrawable(); + image.setImageDrawable(null); + image.setImageDrawable(d); + + image.setTranslationX(0f); + image.setTranslationY(0f); + image.setScaleX(1f); + image.setScaleY(1f); + image.setElevation(0f); + } + + @Nullable + @Override + public Drawable getPhotoViewCurrentDrawable(int index){ + return image.getDrawable(); + } + + @Override + public void photoViewerDismissed(){ + photoViewer=null; + } + + @Override + public void onRequestPermissions(String[] permissions){ + + } + }); + photoViewer.removeMenu(); } } diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/EditTimelinesFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/EditTimelinesFragment.java index 5d7503f5b..3fc17b752 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/EditTimelinesFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/EditTimelinesFragment.java @@ -16,7 +16,6 @@ import android.view.MotionEvent; import android.view.SubMenu; import android.view.View; import android.view.ViewGroup; -import android.view.inputmethod.InputMethodManager; import android.widget.Button; import android.widget.EditText; import android.widget.ImageButton; @@ -35,10 +34,11 @@ import androidx.recyclerview.widget.RecyclerView; import com.hootsuite.nachos.NachoTextView; -import org.joinmastodon.android.GlobalUserPreferences; import org.joinmastodon.android.R; import org.joinmastodon.android.api.requests.lists.GetLists; import org.joinmastodon.android.api.requests.tags.GetFollowedHashtags; +import org.joinmastodon.android.api.session.AccountLocalPreferences; +import org.joinmastodon.android.api.session.AccountSessionManager; import org.joinmastodon.android.model.Hashtag; import org.joinmastodon.android.model.HeaderPaginationList; import org.joinmastodon.android.model.ListTimeline; @@ -52,6 +52,7 @@ import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.function.Consumer; import me.grishka.appkit.api.Callback; @@ -59,7 +60,7 @@ import me.grishka.appkit.api.ErrorResponse; import me.grishka.appkit.utils.BindableViewHolder; import me.grishka.appkit.views.UsableRecyclerView; -public class EditTimelinesFragment extends RecyclerFragment implements ScrollableToTop { +public class EditTimelinesFragment extends MastodonRecyclerFragment implements ScrollableToTop { private String accountID; private TimelinesAdapter adapter; private final ItemTouchHelper itemTouchHelper; @@ -121,7 +122,7 @@ public class EditTimelinesFragment extends RecyclerFragment super.onViewCreated(view, savedInstanceState); itemTouchHelper.attachToRecyclerView(list); refreshLayout.setEnabled(false); - list.addItemDecoration(new DividerItemDecoration(getActivity(), R.attr.colorPollVoted, 0.5f, 56, 16)); + list.addItemDecoration(new DividerItemDecoration(getActivity(), R.attr.colorM3OutlineVariant, 0.5f, 56, 16)); } @Override @@ -187,7 +188,7 @@ public class EditTimelinesFragment extends RecyclerFragment makeBackItem(listsMenu); makeBackItem(hashtagsMenu); - TimelineDefinition.getAllTimelines(accountID).forEach(tl -> addTimelineToOptions(tl, timelinesMenu)); + TimelineDefinition.getAllTimelines(accountID).stream().forEach(tl -> addTimelineToOptions(tl, timelinesMenu)); listTimelines.stream().map(TimelineDefinition::ofList).forEach(tl -> addTimelineToOptions(tl, listsMenu)); addHashtagItem = addOptionsItem(hashtagsMenu, getContext().getString(R.string.sk_timelines_add), R.drawable.ic_fluent_add_24_regular); hashtags.stream().map(TimelineDefinition::ofHashtag).forEach(tl -> addTimelineToOptions(tl, hashtagsMenu)); @@ -200,10 +201,12 @@ public class EditTimelinesFragment extends RecyclerFragment } private void saveTimelines() { - updated = true; - GlobalUserPreferences.pinnedTimelines.put(accountID, data.size() > 0 ? data : List.of(TimelineDefinition.HOME_TIMELINE)); - GlobalUserPreferences.save(); - } + updated=true; + AccountLocalPreferences prefs=AccountSessionManager.get(accountID).getLocalPreferences(); + if(data.isEmpty()) data.add(TimelineDefinition.HOME_TIMELINE); + prefs.timelines=data; + prefs.save(); + } private void removeTimeline(int position) { data.remove(position); @@ -214,7 +217,7 @@ public class EditTimelinesFragment extends RecyclerFragment @Override protected void doLoadData(int offset, int count){ - onDataLoaded(GlobalUserPreferences.pinnedTimelines.getOrDefault(accountID, TimelineDefinition.getDefaultTimelines(accountID)), false); + onDataLoaded(AccountSessionManager.get(accountID).getLocalPreferences().timelines); updateOptionsMenu(); } @@ -256,7 +259,8 @@ public class EditTimelinesFragment extends RecyclerFragment Context ctx = getContext(); View view = getActivity().getLayoutInflater().inflate(R.layout.edit_timeline, list, false); - Button advancedBtn = view.findViewById(R.id.advanced); + View divider = view.findViewById(R.id.divider); + Button advancedBtn = view.findViewById(R.id.advanced); EditText editText = view.findViewById(R.id.input); if (item != null) editText.setText(item.getCustomTitle()); editText.setHint(item != null ? item.getDefaultTitle(ctx) : ctx.getString(R.string.sk_hashtag)); @@ -264,11 +268,12 @@ public class EditTimelinesFragment extends RecyclerFragment LinearLayout tagWrap = view.findViewById(R.id.tag_wrap); boolean advancedOptionsAvailable = item == null || item.getType() == TimelineDefinition.TimelineType.HASHTAG; advancedBtn.setVisibility(advancedOptionsAvailable ? View.VISIBLE : View.GONE); - view.findViewById(R.id.divider).setVisibility(advancedOptionsAvailable ? View.VISIBLE : View.GONE); advancedBtn.setOnClickListener(l -> { advancedBtn.setSelected(!advancedBtn.isSelected()); - advancedBtn.setText(advancedBtn.isSelected() ? R.string.sk_advanced_options_hide : R.string.sk_advanced_options_show); + advancedBtn.setText(advancedBtn.isSelected() ? R.string.sk_advanced_options_hide : R.string.sk_advanced_options_show); + divider.setVisibility(advancedBtn.isSelected() ? View.VISIBLE : View.GONE); tagWrap.setVisibility(advancedBtn.isSelected() ? View.VISIBLE : View.GONE); + UiUtils.beginLayoutTransition((ViewGroup) view); }); Switch localOnlySwitch = view.findViewById(R.id.local_only_switch); @@ -281,8 +286,9 @@ public class EditTimelinesFragment extends RecyclerFragment NachoTextView tagsNone = prepareChipTextView(view.findViewById(R.id.tags_none)); if (item != null) { tagMain.setText(item.getHashtagName()); - boolean hasAdvanced = setTagListContent(tagsAny, item.getHashtagAny()); - hasAdvanced = setTagListContent(tagsAll, item.getHashtagAll()) || hasAdvanced; + boolean hasAdvanced = !TextUtils.isEmpty(item.getCustomTitle()) && !Objects.equals(item.getHashtagName(), item.getCustomTitle()); + hasAdvanced = setTagListContent(tagsAny, item.getHashtagAny()) || hasAdvanced; + hasAdvanced = setTagListContent(tagsAll, item.getHashtagAll()) || hasAdvanced; hasAdvanced = setTagListContent(tagsNone, item.getHashtagNone()) || hasAdvanced; if (item.isHashtagLocalOnly()) { localOnlySwitch.setChecked(true); @@ -291,7 +297,8 @@ public class EditTimelinesFragment extends RecyclerFragment if (hasAdvanced) { advancedBtn.setSelected(true); advancedBtn.setText(R.string.sk_advanced_options_hide); - tagWrap.setVisibility(View.VISIBLE); + tagWrap.setVisibility(View.VISIBLE); + divider.setVisibility(View.VISIBLE); } } diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/FavoritedStatusListFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/FavoritedStatusListFragment.java index e649bc7ba..d5c2577af 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/FavoritedStatusListFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/FavoritedStatusListFragment.java @@ -5,7 +5,7 @@ import android.net.Uri; import org.joinmastodon.android.R; import org.joinmastodon.android.api.requests.statuses.GetFavoritedStatuses; -import org.joinmastodon.android.model.Filter; +import org.joinmastodon.android.model.FilterContext; import org.joinmastodon.android.model.HeaderPaginationList; import org.joinmastodon.android.model.Status; @@ -39,8 +39,8 @@ public class FavoritedStatusListFragment extends StatusListFragment{ } @Override - protected Filter.FilterContext getFilterContext() { - return Filter.FilterContext.ACCOUNT; + protected FilterContext getFilterContext() { + return FilterContext.ACCOUNT; } @Override diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/FeaturedHashtagsListFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/FeaturedHashtagsListFragment.java new file mode 100644 index 000000000..45d0e58c1 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/FeaturedHashtagsListFragment.java @@ -0,0 +1,64 @@ +package org.joinmastodon.android.fragments; + +import android.graphics.Canvas; +import android.graphics.Paint; +import android.net.Uri; +import android.os.Bundle; +import android.view.View; + +import org.joinmastodon.android.R; +import org.joinmastodon.android.model.Account; +import org.joinmastodon.android.model.Hashtag; +import org.joinmastodon.android.ui.displayitems.HashtagStatusDisplayItem; +import org.joinmastodon.android.ui.displayitems.StatusDisplayItem; +import org.joinmastodon.android.ui.utils.UiUtils; +import org.parceler.Parcels; + +import java.util.Collections; +import java.util.List; +import java.util.Objects; +import java.util.stream.Collectors; + +import androidx.recyclerview.widget.RecyclerView; + +public class FeaturedHashtagsListFragment extends BaseStatusListFragment{ + private Account account; + private String accountID; + + @Override + public void onCreate(Bundle savedInstanceState){ + super.onCreate(savedInstanceState); + accountID=getArguments().getString("account"); + account=Parcels.unwrap(getArguments().getParcelable("profileAccount")); + onDataLoaded(getArguments().getParcelableArrayList("hashtags").stream().map(p->(Hashtag)Parcels.unwrap(p)).collect(Collectors.toList()), false); + setTitle(R.string.hashtags); + } + + @Override + protected List buildDisplayItems(Hashtag s){ + return Collections.singletonList(new HashtagStatusDisplayItem(s.name, this, s)); + } + + @Override + protected void addAccountToKnown(Hashtag s){ + + } + + @Override + public void onItemClick(String id){ + UiUtils.openHashtagTimeline(getActivity(), accountID, id, data.stream().filter(h -> Objects.equals(h.name, id)).findAny().map(h -> h.following).orElse(null)); + } + + @Override + protected void doLoadData(int offset, int count){} + + @Override + protected void drawDivider(View child, View bottomSibling, RecyclerView.ViewHolder holder, RecyclerView.ViewHolder siblingHolder, RecyclerView parent, Canvas c, Paint paint){ + // no-op + } + + @Override + public Uri getWebUri(Uri.Builder base){ + return null; // TODO + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/FollowRequestsListFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/FollowRequestsListFragment.java index 17e9ce0de..d5cdf7cc3 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/FollowRequestsListFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/FollowRequestsListFragment.java @@ -48,7 +48,7 @@ import me.grishka.appkit.utils.BindableViewHolder; import me.grishka.appkit.utils.V; import me.grishka.appkit.views.UsableRecyclerView; -public class FollowRequestsListFragment extends RecyclerFragment implements ScrollableToTop, ProvidesAssistContent.ProvidesWebUri { +public class FollowRequestsListFragment extends MastodonRecyclerFragment implements ScrollableToTop, ProvidesAssistContent.ProvidesWebUri { private String accountID; private Map relationships=Collections.emptyMap(); private GetAccountRelationships relationshipsRequest; @@ -254,7 +254,7 @@ public class FollowRequestsListFragment extends RecyclerFragment { + if(getContext()==null) return; itemView.setHasTransientState(false); relationships.put(item.account.id, rel); RecyclerView.Adapter adapter = getBindingAdapter(); @@ -328,6 +329,7 @@ public class FollowRequestsListFragment extends RecyclerFragment{ + if(getContext()==null) return; itemView.setHasTransientState(false); relationships.put(item.account.id, rel); rebind(); diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/FollowedHashtagsFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/FollowedHashtagsFragment.java index e2e6ae59c..8a0fb7a55 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/FollowedHashtagsFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/FollowedHashtagsFragment.java @@ -21,7 +21,7 @@ import me.grishka.appkit.api.SimpleCallback; import me.grishka.appkit.utils.BindableViewHolder; import me.grishka.appkit.views.UsableRecyclerView; -public class FollowedHashtagsFragment extends RecyclerFragment implements ScrollableToTop, ProvidesAssistContent.ProvidesWebUri { +public class FollowedHashtagsFragment extends MastodonRecyclerFragment implements ScrollableToTop, ProvidesAssistContent.ProvidesWebUri { private String nextMaxID; private String accountID; @@ -47,7 +47,7 @@ public class FollowedHashtagsFragment extends RecyclerFragment implemen @Override public void onViewCreated(View view, Bundle savedInstanceState) { super.onViewCreated(view, savedInstanceState); - list.addItemDecoration(new DividerItemDecoration(getActivity(), R.attr.colorPollVoted, 0.5f, 56, 16)); + list.addItemDecoration(new DividerItemDecoration(getActivity(), R.attr.colorM3OutlineVariant, 0.5f, 56, 16)); } @Override diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/HasAccountID.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/HasAccountID.java index 5e7f01fed..9934b2e17 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/HasAccountID.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/HasAccountID.java @@ -1,5 +1,6 @@ package org.joinmastodon.android.fragments; +import org.joinmastodon.android.api.session.AccountLocalPreferences; import org.joinmastodon.android.api.session.AccountSession; import org.joinmastodon.android.api.session.AccountSessionManager; import org.joinmastodon.android.model.Instance; @@ -24,4 +25,8 @@ public interface HasAccountID { default Optional getInstance() { return getSession().getInstance(); } + + default AccountLocalPreferences getLocalPrefs() { + return AccountSessionManager.get(getAccountID()).getLocalPreferences(); + } } diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/HasElevationOnScrollListener.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/HasElevationOnScrollListener.java new file mode 100644 index 000000000..0a110af44 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/HasElevationOnScrollListener.java @@ -0,0 +1,7 @@ +package org.joinmastodon.android.fragments; + +import org.joinmastodon.android.utils.ElevationOnScrollListener; + +public interface HasElevationOnScrollListener { + ElevationOnScrollListener getElevationOnScrollListener(); +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/HashtagTimelineFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/HashtagTimelineFragment.java index 99122001c..57cb1114c 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/HashtagTimelineFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/HashtagTimelineFragment.java @@ -17,7 +17,7 @@ import org.joinmastodon.android.api.requests.tags.GetHashtag; import org.joinmastodon.android.api.requests.tags.SetHashtagFollowed; import org.joinmastodon.android.api.requests.timelines.GetHashtagTimeline; import org.joinmastodon.android.events.HashtagUpdatedEvent; -import org.joinmastodon.android.model.Filter; +import org.joinmastodon.android.model.FilterContext; import org.joinmastodon.android.model.Hashtag; import org.joinmastodon.android.model.Status; import org.joinmastodon.android.model.TimelineDefinition; @@ -126,7 +126,7 @@ public class HashtagTimelineFragment extends PinnableStatusListFragment { @Override protected void doLoadData(int offset, int count){ - currentRequest=new GetHashtagTimeline(hashtag, offset==0 ? null : getMaxID(), null, count, any, all, none, localOnly) + currentRequest=new GetHashtagTimeline(hashtag, offset==0 ? null : getMaxID(), null, count, any, all, none, localOnly, getLocalPrefs().timelineReplyVisibility) .setCallback(new SimpleCallback<>(this){ @Override public void onSuccess(List result){ @@ -160,12 +160,12 @@ public class HashtagTimelineFragment extends PinnableStatusListFragment { @Override protected void onSetFabBottomInset(int inset){ - ((ViewGroup.MarginLayoutParams) fab.getLayoutParams()).bottomMargin=V.dp(24)+inset; + ((ViewGroup.MarginLayoutParams) fab.getLayoutParams()).bottomMargin=V.dp(16)+inset; } @Override - protected Filter.FilterContext getFilterContext() { - return Filter.FilterContext.PUBLIC; + protected FilterContext getFilterContext() { + return FilterContext.PUBLIC; } @Override diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/HomeFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/HomeFragment.java index fbbfb26f3..bf59b2bb1 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/HomeFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/HomeFragment.java @@ -1,21 +1,21 @@ package org.joinmastodon.android.fragments; +import android.annotation.SuppressLint; import android.app.Fragment; import android.app.NotificationManager; import android.app.assist.AssistContent; -import android.graphics.Outline; import android.os.Build; import android.os.Bundle; import android.service.notification.StatusBarNotification; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; -import android.view.ViewOutlineProvider; import android.view.ViewTreeObserver; import android.view.WindowInsets; import android.widget.FrameLayout; import android.widget.ImageView; import android.widget.LinearLayout; +import android.widget.TextView; import androidx.annotation.IdRes; import androidx.annotation.Nullable; @@ -23,26 +23,27 @@ import androidx.annotation.Nullable; import com.squareup.otto.Subscribe; import org.joinmastodon.android.E; +import org.joinmastodon.android.GlobalUserPreferences; import org.joinmastodon.android.R; -import org.joinmastodon.android.api.requests.notifications.GetNotifications; import org.joinmastodon.android.api.session.AccountSession; import org.joinmastodon.android.api.session.AccountSessionManager; -import org.joinmastodon.android.events.AllNotificationsSeenEvent; -import org.joinmastodon.android.events.NotificationReceivedEvent; +import org.joinmastodon.android.events.NotificationsMarkerUpdatedEvent; +import org.joinmastodon.android.events.StatusDisplaySettingsChangedEvent; import org.joinmastodon.android.fragments.discover.DiscoverFragment; import org.joinmastodon.android.model.Account; import org.joinmastodon.android.model.Instance; import org.joinmastodon.android.model.Notification; +import org.joinmastodon.android.model.PaginatedResponse; import org.joinmastodon.android.ui.AccountSwitcherSheet; +import org.joinmastodon.android.ui.OutlineProviders; import org.joinmastodon.android.ui.utils.UiUtils; import org.joinmastodon.android.ui.views.TabBar; +import org.joinmastodon.android.utils.ObjectIdComparator; import org.joinmastodon.android.utils.ProvidesAssistContent; import org.parceler.Parcels; import java.util.ArrayList; -import java.util.EnumSet; import java.util.List; -import java.util.Optional; import me.grishka.appkit.FragmentStackActivity; import me.grishka.appkit.api.Callback; @@ -59,42 +60,38 @@ public class HomeFragment extends AppKitFragment implements OnBackPressedListene private FragmentRootLinearLayout content; private HomeTabFragment homeTabFragment; private NotificationsFragment notificationsFragment; - private DiscoverFragment searchFragment; + private DiscoverFragment discoverFragment; private ProfileFragment profileFragment; private TabBar tabBar; private View tabBarWrap; private ImageView tabBarAvatar; - private ImageView notificationTabIcon; @IdRes private int currentTab=R.id.tab_home; + private TextView notificationsBadge; private String accountID; - private boolean isPleroma; + private boolean isAkkoma; @Override public void onCreate(Bundle savedInstanceState){ super.onCreate(savedInstanceState); - E.register(this); accountID=getArguments().getString("account"); setTitle(R.string.sk_app_name); - isPleroma = AccountSessionManager.getInstance().getAccount(accountID).getInstance() - .map(Instance::isAkkoma) - .orElse(false); + isAkkoma = getInstance().map(Instance::isAkkoma).orElse(false); if(Build.VERSION.SDK_INT>=Build.VERSION_CODES.N) setRetainInstance(true); - // TODO: clean up if(savedInstanceState==null){ Bundle args=new Bundle(); args.putString("account", accountID); homeTabFragment=new HomeTabFragment(); homeTabFragment.setArguments(args); args=new Bundle(args); - args.putBoolean("disableDiscover", isPleroma); + args.putBoolean("disableDiscover", isAkkoma); args.putBoolean("noAutoLoad", true); - searchFragment=new DiscoverFragment(); - searchFragment.setArguments(args); + discoverFragment=new DiscoverFragment(); + discoverFragment.setArguments(args); notificationsFragment=new NotificationsFragment(); notificationsFragment.setArguments(args); args=new Bundle(args); @@ -104,6 +101,13 @@ public class HomeFragment extends AppKitFragment implements OnBackPressedListene profileFragment.setArguments(args); } + E.register(this); + } + + @Override + public void onDestroy(){ + super.onDestroy(); + E.unregister(this); } @Nullable @@ -121,24 +125,28 @@ public class HomeFragment extends AppKitFragment implements OnBackPressedListene tabBar.setListeners(this::onTabSelected, this::onTabLongClick); tabBarWrap=content.findViewById(R.id.tabbar_wrap); - tabBarAvatar=tabBar.findViewById(R.id.tab_profile_ava); - tabBarAvatar.setOutlineProvider(new ViewOutlineProvider(){ - @Override - public void getOutline(View view, Outline outline){ - outline.setOval(0, 0, view.getWidth(), view.getHeight()); + // this one's for the pill haters (https://m3.material.io/components/navigation-bar/overview) + if (GlobalUserPreferences.disableM3PillActiveIndicator) { + for(int i=0; i=27){ int inset=insets.getSystemWindowInsetBottom(); - tabBarWrap.setPadding(0, 0, 0, inset>0 ? Math.max(inset, V.dp(36)) : 0); + tabBarWrap.setPadding(0, 0, 0, inset>0 ? Math.max(inset, V.dp(24)) : 0); super.onApplyWindowInsets(insets.replaceSystemWindowInsets(insets.getSystemWindowInsetLeft(), 0, insets.getSystemWindowInsetRight(), 0)); }else{ super.onApplyWindowInsets(insets.replaceSystemWindowInsets(insets.getSystemWindowInsetLeft(), 0, insets.getSystemWindowInsetRight(), insets.getSystemWindowInsetBottom())); } WindowInsets topOnlyInsets=insets.replaceSystemWindowInsets(0, insets.getSystemWindowInsetTop(), 0, 0); homeTabFragment.onApplyWindowInsets(topOnlyInsets); - searchFragment.onApplyWindowInsets(topOnlyInsets); + discoverFragment.onApplyWindowInsets(topOnlyInsets); notificationsFragment.onApplyWindowInsets(topOnlyInsets); profileFragment.onApplyWindowInsets(topOnlyInsets); } @@ -217,7 +225,7 @@ public class HomeFragment extends AppKitFragment implements OnBackPressedListene if(tab==R.id.tab_home){ return homeTabFragment; }else if(tab==R.id.tab_search){ - return searchFragment; + return discoverFragment; }else if(tab==R.id.tab_notifications){ return notificationsFragment; }else if(tab==R.id.tab_profile){ @@ -235,11 +243,8 @@ public class HomeFragment extends AppKitFragment implements OnBackPressedListene private void onTabSelected(@IdRes int tab){ Fragment newFragment=fragmentForTab(tab); - if(tab==currentTab){ - if (tab == R.id.tab_search) - searchFragment.onSelect(); - else if(newFragment instanceof ScrollableToTop scrollable) - scrollable.scrollToTop(); + if(tab==currentTab && newFragment instanceof ScrollableToTop scrollable) { + scrollable.scrollToTop(); return; } getChildFragmentManager().beginTransaction().hide(fragmentForTab(currentTab)).show(newFragment).commit(); @@ -247,7 +252,6 @@ public class HomeFragment extends AppKitFragment implements OnBackPressedListene if (newFragment instanceof HasFab fabulous && !fabulous.isScrolling()) fabulous.showFab(); currentTab=tab; ((FragmentStackActivity)getActivity()).invalidateSystemBarColors(this); - if (tab == R.id.tab_search && isPleroma) searchFragment.selectSearch(); } private void maybeTriggerLoading(Fragment newFragment){ @@ -258,7 +262,6 @@ public class HomeFragment extends AppKitFragment implements OnBackPressedListene ((DiscoverFragment) newFragment).loadData(); }else if(newFragment instanceof NotificationsFragment){ ((NotificationsFragment) newFragment).loadData(); - // TODO make an interface? NotificationManager nm=getActivity().getSystemService(NotificationManager.class); for (StatusBarNotification notification : nm.getActiveNotifications()) { if (accountID.equals(notification.getTag())) { @@ -276,6 +279,11 @@ public class HomeFragment extends AppKitFragment implements OnBackPressedListene } new AccountSwitcherSheet(getActivity(), this).show(); return true; + } else if(tab==R.id.tab_search){ + tabBar.selectTab(R.id.tab_search); + onTabSelected(R.id.tab_search); + discoverFragment.openSearch(); + return true; } return false; } @@ -285,7 +293,7 @@ public class HomeFragment extends AppKitFragment implements OnBackPressedListene if(currentTab==R.id.tab_profile) if (profileFragment.onBackPressed()) return true; if(currentTab==R.id.tab_search) - if (searchFragment.onBackPressed()) return true; + if (discoverFragment.onBackPressed()) return true; if (currentTab!=R.id.tab_home) { tabBar.selectTab(R.id.tab_home); onTabSelected(R.id.tab_home); @@ -300,52 +308,79 @@ public class HomeFragment extends AppKitFragment implements OnBackPressedListene super.onSaveInstanceState(outState); outState.putInt("selectedTab", currentTab); if (homeTabFragment.isAdded()) getChildFragmentManager().putFragment(outState, "homeTabFragment", homeTabFragment); - if (searchFragment.isAdded()) getChildFragmentManager().putFragment(outState, "searchFragment", searchFragment); + if (discoverFragment.isAdded()) getChildFragmentManager().putFragment(outState, "searchFragment", discoverFragment); if (notificationsFragment.isAdded()) getChildFragmentManager().putFragment(outState, "notificationsFragment", notificationsFragment); if (profileFragment.isAdded()) getChildFragmentManager().putFragment(outState, "profileFragment", profileFragment); } - public void updateNotificationBadge() { - AccountSession session = AccountSessionManager.getInstance().getAccount(accountID); - Optional instance = session.getInstance(); - if (instance.isEmpty()) return; // avoiding incompatibility with akkoma - - new GetNotifications(null, 1, EnumSet.allOf(Notification.Type.class), instance.get().isAkkoma()) - .setCallback(new Callback<>() { - @Override - public void onSuccess(List notifications) { - if (notifications.size() > 0) { - try { - long newestId = Long.parseLong(notifications.get(0).id); - long lastSeenId = Long.parseLong(session.markers.notifications.lastReadId); - setNotificationBadge(newestId > lastSeenId); - } catch (Exception ignored) { - setNotificationBadge(false); - } - } - } - - @Override - public void onError(ErrorResponse error) { - setNotificationBadge(false); - } - }).exec(accountID); + @Override + protected void onShown(){ + super.onShown(); + reloadNotificationsForUnreadCount(); } - public void setNotificationBadge(boolean badge) { - notificationTabIcon.setImageResource(badge - ? R.drawable.ic_fluent_alert_28_selector_badged - : R.drawable.ic_fluent_alert_28_selector); + public void reloadNotificationsForUnreadCount(){ + List[] notifications=new List[]{null}; + String[] marker={null}; + + AccountSessionManager.get(accountID).reloadNotificationsMarker(m->{ + marker[0]=m; + if(notifications[0]!=null){ + updateUnreadCount(notifications[0], marker[0]); + } + }); + + AccountSessionManager.get(accountID).getCacheController().getNotifications(null, 40, false, false, true, new Callback<>(){ + @Override + public void onSuccess(PaginatedResponse> result){ + notifications[0]=result.items; + if(marker[0]!=null) + updateUnreadCount(notifications[0], marker[0]); + } + + @Override + public void onError(ErrorResponse error){} + }); + } + + @SuppressLint("DefaultLocale") + private void updateUnreadCount(List notifications, String marker){ + if(notifications.isEmpty() || ObjectIdComparator.INSTANCE.compare(notifications.get(0).id, marker)<=0){ + V.setVisibilityAnimated(notificationsBadge, View.GONE); + }else{ + V.setVisibilityAnimated(notificationsBadge, View.VISIBLE); + if(ObjectIdComparator.INSTANCE.compare(notifications.get(notifications.size()-1).id, marker)>0){ + notificationsBadge.setText(String.format("%d+", notifications.size())); + }else{ + int count=0; + for(Notification n:notifications){ + if(n.id.equals(marker)) + break; + count++; + } + notificationsBadge.setText(String.format("%d", count)); + } + } } @Subscribe - public void onNotificationReceived(NotificationReceivedEvent notificationReceivedEvent) { - if (notificationReceivedEvent.account.equals(accountID)) setNotificationBadge(true); + public void onNotificationsMarkerUpdated(NotificationsMarkerUpdatedEvent ev){ + if(!ev.accountID.equals(accountID)) + return; + if(ev.clearUnread) + V.setVisibilityAnimated(notificationsBadge, View.GONE); } @Subscribe - public void onAllNotificationsSeen(AllNotificationsSeenEvent allNotificationsSeenEvent) { - setNotificationBadge(false); + public void onStatusDisplaySettingsChanged(StatusDisplaySettingsChangedEvent ev){ + if(!ev.accountID.equals(accountID)) + return; + if(homeTabFragment.getCurrentFragment() instanceof LoaderFragment lf && lf.loaded + && lf instanceof BaseStatusListFragment homeTimelineFragment) + homeTimelineFragment.rebuildAllDisplayItems(); + if(notificationsFragment.getCurrentFragment() instanceof LoaderFragment lf && lf.loaded + && lf instanceof BaseStatusListFragment l) + l.rebuildAllDisplayItems(); } @Override diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/HomeTabFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/HomeTabFragment.java index d9b4a38e7..93ef046e3 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/HomeTabFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/HomeTabFragment.java @@ -12,6 +12,7 @@ import android.app.Fragment; import android.app.FragmentTransaction; import android.app.assist.AssistContent; import android.content.Context; +import android.content.res.Configuration; import android.os.Build; import android.os.Bundle; import android.view.LayoutInflater; @@ -43,10 +44,12 @@ import org.joinmastodon.android.R; import org.joinmastodon.android.api.requests.announcements.GetAnnouncements; import org.joinmastodon.android.api.requests.lists.GetLists; import org.joinmastodon.android.api.requests.tags.GetFollowedHashtags; +import org.joinmastodon.android.api.session.AccountSessionManager; import org.joinmastodon.android.events.HashtagUpdatedEvent; import org.joinmastodon.android.events.ListDeletedEvent; import org.joinmastodon.android.events.ListUpdatedCreatedEvent; import org.joinmastodon.android.events.SelfUpdateStateChangedEvent; +import org.joinmastodon.android.fragments.settings.SettingsMainFragment; import org.joinmastodon.android.model.Announcement; import org.joinmastodon.android.model.Hashtag; import org.joinmastodon.android.model.HeaderPaginationList; @@ -55,6 +58,7 @@ import org.joinmastodon.android.model.TimelineDefinition; import org.joinmastodon.android.ui.SimpleViewHolder; import org.joinmastodon.android.ui.utils.UiUtils; import org.joinmastodon.android.updater.GithubSelfUpdater; +import org.joinmastodon.android.utils.ElevationOnScrollListener; import org.joinmastodon.android.utils.ProvidesAssistContent; import java.util.Collection; @@ -72,8 +76,9 @@ import me.grishka.appkit.fragments.BaseRecyclerFragment; import me.grishka.appkit.fragments.OnBackPressedListener; import me.grishka.appkit.utils.CubicBezierInterpolator; import me.grishka.appkit.utils.V; +import me.grishka.appkit.views.FragmentRootLinearLayout; -public class HomeTabFragment extends MastodonToolbarFragment implements ScrollableToTop, OnBackPressedListener, HasFab, ProvidesAssistContent { +public class HomeTabFragment extends MastodonToolbarFragment implements ScrollableToTop, OnBackPressedListener, HasFab, ProvidesAssistContent, HasElevationOnScrollListener { private static final int ANNOUNCEMENTS_RESULT = 654; private String accountID; @@ -91,7 +96,7 @@ public class HomeTabFragment extends MastodonToolbarFragment implements Scrollab private PopupMenu switcherPopup; private final Map listItems = new HashMap<>(); private final Map hashtagsItems = new HashMap<>(); - private List timelineDefinitions; + private List timelinesList; private int count; private Fragment[] fragments; private FrameLayout[] tabViews; @@ -102,19 +107,20 @@ public class HomeTabFragment extends MastodonToolbarFragment implements Scrollab private View overflowActionView = null; private boolean announcementsBadged, settingsBadged; private ImageButton fab; + private ElevationOnScrollListener elevationOnScrollListener; @Override public void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); E.register(this); accountID = getArguments().getString("account"); - timelineDefinitions = GlobalUserPreferences.pinnedTimelines.getOrDefault(accountID, TimelineDefinition.getDefaultTimelines(accountID)); - assert timelineDefinitions != null; - if (timelineDefinitions.size() == 0) timelineDefinitions = List.of(TimelineDefinition.HOME_TIMELINE); - count = timelineDefinitions.size(); - fragments = new Fragment[count]; - tabViews = new FrameLayout[count]; - timelines = new TimelineDefinition[count]; + timelinesList=AccountSessionManager.get(accountID).getLocalPreferences().timelines; + assert timelinesList!=null; + if(timelinesList.isEmpty()) timelinesList=List.of(TimelineDefinition.HOME_TIMELINE); + count=timelinesList.size(); + fragments=new Fragment[count]; + tabViews=new FrameLayout[count]; + timelines=new TimelineDefinition[count]; } @Override @@ -125,7 +131,11 @@ public class HomeTabFragment extends MastodonToolbarFragment implements Scrollab @Override public View onCreateContentView(LayoutInflater inflater, ViewGroup container, Bundle bundle) { + FragmentRootLinearLayout rootView = new FragmentRootLinearLayout(getContext()); + rootView.setLayoutParams(new ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT)); FrameLayout view = new FrameLayout(getContext()); + view.setLayoutParams(new ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT)); + rootView.addView(view); inflater.inflate(R.layout.compose_fab, view); fab = view.findViewById(R.id.fab); fab.setOnClickListener(this::onFabClick); @@ -140,8 +150,8 @@ public class HomeTabFragment extends MastodonToolbarFragment implements Scrollab args.putBoolean("__disable_fab", true); args.putBoolean("onlyPosts", true); - for (int i = 0; i < timelineDefinitions.size(); i++) { - TimelineDefinition tl = timelineDefinitions.get(i); + for (int i=0; i < timelinesList.size(); i++) { + TimelineDefinition tl = timelinesList.get(i); fragments[i] = tl.getFragment(); timelines[i] = tl; } @@ -168,7 +178,7 @@ public class HomeTabFragment extends MastodonToolbarFragment implements Scrollab overflowActionView.setOnClickListener(l -> overflowPopup.show()); overflowActionView.setOnTouchListener(overflowPopup.getDragToOpenListener()); - return view; + return rootView; } @SuppressLint("ClickableViewAccessibility") @@ -243,6 +253,8 @@ public class HomeTabFragment extends MastodonToolbarFragment implements Scrollab }); } + elevationOnScrollListener = new ElevationOnScrollListener((FragmentRootLinearLayout) view, getToolbar()); + if(GithubSelfUpdater.needSelfUpdating()){ updateUpdateState(GithubSelfUpdater.getInstance().getState()); } @@ -289,6 +301,10 @@ public class HomeTabFragment extends MastodonToolbarFragment implements Scrollab }).exec(accountID); } + public ElevationOnScrollListener getElevationOnScrollListener() { + return elevationOnScrollListener; + } + private void onFabClick(View v){ if (fragments[pager.getCurrentItem()] instanceof BaseStatusListFragment l) { l.onFabClick(v); @@ -466,10 +482,19 @@ public class HomeTabFragment extends MastodonToolbarFragment implements Scrollab && fabulous.isScrolling(); } + @Override + public void onConfigurationChanged(Configuration newConfig) { + super.onConfigurationChanged(newConfig); + if (elevationOnScrollListener != null) elevationOnScrollListener.setViews(getToolbar()); + } + private void updateSwitcherIcon(int i) { timelineIcon.setImageResource(timelines[i].getIcon().iconRes); timelineTitle.setText(timelines[i].getTitle(getContext())); showFab(); + if (elevationOnScrollListener != null && getCurrentFragment() instanceof IsOnTop f) { + elevationOnScrollListener.handleScroll(getContext(), f.isOnTop()); + } } @Override @@ -484,7 +509,7 @@ public class HomeTabFragment extends MastodonToolbarFragment implements Scrollab getToolbar().post(() -> overflowPopup.show()); return true; } else if (id == R.id.settings || id == R.id.settings_action) { - Nav.go(getActivity(), SettingsFragment.class, args); + Nav.go(getActivity(), SettingsMainFragment.class, args); } else if (id == R.id.announcements || id == R.id.announcements_action) { Nav.goForResult(getActivity(), AnnouncementsFragment.class, args, ANNOUNCEMENTS_RESULT, this); } else if (id == R.id.edit_timelines) { @@ -633,8 +658,8 @@ public class HomeTabFragment extends MastodonToolbarFragment implements Scrollab @Override protected void onShown() { super.onShown(); - Object pinnedTimelines = GlobalUserPreferences.pinnedTimelines.get(accountID); - if (pinnedTimelines != null && timelineDefinitions != pinnedTimelines) UiUtils.restartApp(); + Object timelines = AccountSessionManager.get(accountID).getLocalPreferences().timelines; + if (timelines != null && timelinesList!= timelines) UiUtils.restartApp(); } @Override @@ -698,6 +723,10 @@ public class HomeTabFragment extends MastodonToolbarFragment implements Scrollab return hashtagsItems.values(); } + public Fragment getCurrentFragment() { + return fragments[pager.getCurrentItem()]; + } + public ImageButton getFab() { return fab; } diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/HomeTimelineFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/HomeTimelineFragment.java index 6b60cbcf9..d511740fe 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/HomeTimelineFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/HomeTimelineFragment.java @@ -11,11 +11,13 @@ import androidx.recyclerview.widget.RecyclerView; import org.joinmastodon.android.GlobalUserPreferences; import org.joinmastodon.android.api.requests.markers.SaveMarkers; import org.joinmastodon.android.api.requests.timelines.GetHomeTimeline; +import org.joinmastodon.android.api.session.AccountLocalPreferences; import org.joinmastodon.android.api.session.AccountSessionManager; import org.joinmastodon.android.events.StatusCreatedEvent; import org.joinmastodon.android.model.CacheablePaginatedResponse; -import org.joinmastodon.android.model.Filter; +import org.joinmastodon.android.model.FilterContext; import org.joinmastodon.android.model.Status; +import org.joinmastodon.android.model.TimelineMarkers; import org.joinmastodon.android.ui.displayitems.GapStatusDisplayItem; import org.joinmastodon.android.ui.displayitems.StatusDisplayItem; import org.joinmastodon.android.utils.StatusFilterPredicate; @@ -49,8 +51,9 @@ public class HomeTimelineFragment extends StatusListFragment { } private boolean typeFilterPredicate(Status s) { - return (GlobalUserPreferences.showReplies || s.inReplyToId == null) && - (GlobalUserPreferences.showBoosts || s.reblog == null); + AccountLocalPreferences lp=getLocalPrefs(); + return (lp.showReplies || s.inReplyToId == null) && + (lp.showBoosts || s.reblog == null); } private List filterPosts(List items) { @@ -110,7 +113,7 @@ public class HomeTimelineFragment extends StatusListFragment { new SaveMarkers(topPostID, null) .setCallback(new Callback<>(){ @Override - public void onSuccess(SaveMarkers.Response result){ + public void onSuccess(TimelineMarkers result){ } @Override @@ -123,8 +126,8 @@ public class HomeTimelineFragment extends StatusListFragment { } } - public void onStatusCreated(StatusCreatedEvent ev){ - prependItems(Collections.singletonList(ev.status), true); + public void onStatusCreated(Status status){ + prependItems(Collections.singletonList(status), true); } private void loadNewPosts(){ @@ -134,7 +137,7 @@ public class HomeTimelineFragment extends StatusListFragment { // we'll get the currently topmost post as last in the response. This way we know there's no gap // between the existing and newly loaded parts of the timeline. String sinceID=data.size()>1 ? data.get(1).id : "1"; - currentRequest=new GetHomeTimeline(null, null, 20, sinceID) + currentRequest=new GetHomeTimeline(null, null, 20, sinceID, getLocalPrefs().timelineReplyVisibility) .setCallback(new Callback<>(){ @Override public void onSuccess(List result){ @@ -151,8 +154,7 @@ public class HomeTimelineFragment extends StatusListFragment { result.get(result.size()-1).hasGapAfter=true; toAdd=result; } - StatusFilterPredicate filterPredicate=new StatusFilterPredicate(accountID, getFilterContext()); - toAdd=toAdd.stream().filter(filterPredicate).collect(Collectors.toList()); + AccountSessionManager.get(accountID).filterStatuses(toAdd, getFilterContext()); if(!toAdd.isEmpty()){ prependItems(toAdd, true); if (parent != null && GlobalUserPreferences.showNewPostsButton) parent.showNewPostsButton(); @@ -169,7 +171,7 @@ public class HomeTimelineFragment extends StatusListFragment { .exec(accountID); if (parent.getParentFragment() instanceof HomeFragment homeFragment) { - homeFragment.updateNotificationBadge(); + homeFragment.reloadNotificationsForUnreadCount(); } } @@ -182,7 +184,7 @@ public class HomeTimelineFragment extends StatusListFragment { V.setVisibilityAnimated(item.text, View.GONE); GapStatusDisplayItem gap=item.getItem(); dataLoading=true; - currentRequest=new GetHomeTimeline(item.getItemID(), null, 20, null) + currentRequest=new GetHomeTimeline(item.getItemID(), null, 20, null, getLocalPrefs().timelineReplyVisibility) .setCallback(new Callback<>(){ @Override public void onSuccess(List result){ @@ -239,6 +241,7 @@ public class HomeTimelineFragment extends StatusListFragment { insertedPosts.add(s); } } + AccountSessionManager.get(accountID).filterStatuses(insertedPosts, getFilterContext()); if(targetList.isEmpty()){ // oops. We didn't add new posts, but at least we know there are none. adapter.notifyItemRemoved(getMainAdapterOffset()+gapPos); @@ -285,8 +288,8 @@ public class HomeTimelineFragment extends StatusListFragment { } @Override - protected Filter.FilterContext getFilterContext() { - return Filter.FilterContext.HOME; + protected FilterContext getFilterContext() { + return FilterContext.HOME; } @Override diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/ListTimelineFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/ListTimelineFragment.java index bc931f919..aaa6e83f1 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/ListTimelineFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/ListTimelineFragment.java @@ -18,7 +18,7 @@ import org.joinmastodon.android.api.requests.lists.UpdateList; import org.joinmastodon.android.api.requests.timelines.GetListTimeline; import org.joinmastodon.android.events.ListDeletedEvent; import org.joinmastodon.android.events.ListUpdatedCreatedEvent; -import org.joinmastodon.android.model.Filter; +import org.joinmastodon.android.model.FilterContext; import org.joinmastodon.android.model.ListTimeline; import org.joinmastodon.android.model.Status; import org.joinmastodon.android.model.TimelineDefinition; @@ -134,7 +134,7 @@ public class ListTimelineFragment extends PinnableStatusListFragment { @Override protected void doLoadData(int offset, int count) { - currentRequest=new GetListTimeline(listID, offset==0 ? null : getMaxID(), null, count, null) + currentRequest=new GetListTimeline(listID, offset==0 ? null : getMaxID(), null, count, null, getLocalPrefs().timelineReplyVisibility) .setCallback(new SimpleCallback<>(this) { @Override public void onSuccess(List result) { @@ -167,8 +167,8 @@ public class ListTimelineFragment extends PinnableStatusListFragment { @Override - protected Filter.FilterContext getFilterContext() { - return Filter.FilterContext.HOME; + protected FilterContext getFilterContext() { + return FilterContext.HOME; } @Override diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/ListsFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/ListsFragment.java index 6f28eb95a..15aba415a 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/ListsFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/ListsFragment.java @@ -42,7 +42,7 @@ import me.grishka.appkit.api.SimpleCallback; import me.grishka.appkit.utils.BindableViewHolder; import me.grishka.appkit.views.UsableRecyclerView; -public class ListsFragment extends RecyclerFragment implements ScrollableToTop, ProvidesAssistContent.ProvidesWebUri { +public class ListsFragment extends MastodonRecyclerFragment implements ScrollableToTop, ProvidesAssistContent.ProvidesWebUri { private String accountID; private String profileAccountId; private final HashMap userInListBefore = new HashMap<>(); @@ -80,7 +80,7 @@ public class ListsFragment extends RecyclerFragment implements Scr @Override public void onViewCreated(View view, Bundle savedInstanceState) { super.onViewCreated(view, savedInstanceState); - list.addItemDecoration(new DividerItemDecoration(getActivity(), R.attr.colorPollVoted, 0.5f, 56, 16)); + list.addItemDecoration(new DividerItemDecoration(getActivity(), R.attr.colorM3OutlineVariant, 0.5f, 56, 16)); } @Override diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/MastodonRecyclerFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/MastodonRecyclerFragment.java new file mode 100644 index 000000000..3d3781f36 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/MastodonRecyclerFragment.java @@ -0,0 +1,87 @@ +package org.joinmastodon.android.fragments; + +import android.os.Bundle; +import android.view.View; +import android.widget.Toolbar; + +import org.joinmastodon.android.R; +import org.joinmastodon.android.ui.utils.UiUtils; +import org.joinmastodon.android.utils.ElevationOnScrollListener; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +import androidx.annotation.CallSuper; +import androidx.swiperefreshlayout.widget.SwipeRefreshLayout; +import me.grishka.appkit.fragments.BaseRecyclerFragment; +import me.grishka.appkit.views.FragmentRootLinearLayout; + +public abstract class MastodonRecyclerFragment extends BaseRecyclerFragment{ + protected ElevationOnScrollListener elevationOnScrollListener; + + public MastodonRecyclerFragment(int perPage){ + super(perPage); + } + + public MastodonRecyclerFragment(int layout, int perPage){ + super(layout, perPage); + } + + protected List getViewsForElevationEffect(){ + Toolbar toolbar=getToolbar(); + return toolbar!=null ? Collections.singletonList(toolbar) : Collections.emptyList(); + } + + @Override + @CallSuper + public void onViewCreated(View view, Bundle savedInstanceState){ + super.onViewCreated(view, savedInstanceState); + if (getParentFragment() instanceof HasElevationOnScrollListener elevator) + list.addOnScrollListener(elevator.getElevationOnScrollListener()); + else if(wantsElevationOnScrollEffect()) + list.addOnScrollListener(elevationOnScrollListener=new ElevationOnScrollListener((FragmentRootLinearLayout) view, getViewsForElevationEffect())); + if(refreshLayout!=null) + setRefreshLayoutColors(refreshLayout); + } + + @Override + @CallSuper + protected void onUpdateToolbar(){ + super.onUpdateToolbar(); + if(elevationOnScrollListener!=null){ + elevationOnScrollListener.setViews(getViewsForElevationEffect()); + } + } + + protected boolean wantsElevationOnScrollEffect(){ + return true; + } + + public List getData() { + return data; + } + + public static void setRefreshLayoutColors(SwipeRefreshLayout l) { + List colors = new ArrayList<>(Arrays.asList( + UiUtils.isDarkTheme() ? R.color.primary_200 : R.color.primary_600, + UiUtils.isDarkTheme() ? R.color.red_primary_200 : R.color.red_primary_600, + UiUtils.isDarkTheme() ? R.color.green_primary_200 : R.color.green_primary_600, + UiUtils.isDarkTheme() ? R.color.blue_primary_200 : R.color.blue_primary_600, + UiUtils.isDarkTheme() ? R.color.purple_200 : R.color.purple_600 + )); + int primary = UiUtils.getThemeColorRes(l.getContext(), + UiUtils.isDarkTheme() ? R.attr.colorPrimary200 : R.attr.colorPrimary600); + if (!colors.contains(primary)) colors.add(0, primary); + int offset = colors.indexOf(primary); + int[] sorted = new int[colors.size()]; + for (int i = 0; i < colors.size(); i++) { + sorted[i] = colors.get((i + offset) % colors.size()); + } + l.setColorSchemeResources(sorted); + int colorBackground=UiUtils.getThemeColor(l.getContext(), R.attr.colorM3Background); + int colorPrimary=UiUtils.getThemeColor(l.getContext(), R.attr.colorM3Primary); + l.setProgressBackgroundColorSchemeColor(UiUtils.alphaBlendColors(colorBackground, colorPrimary, 0.11f)); + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/MastodonToolbarFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/MastodonToolbarFragment.java index f690f1a39..cb61c0b9d 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/MastodonToolbarFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/MastodonToolbarFragment.java @@ -11,6 +11,15 @@ import androidx.annotation.CallSuper; import me.grishka.appkit.fragments.ToolbarFragment; public abstract class MastodonToolbarFragment extends ToolbarFragment{ + + public MastodonToolbarFragment(){ + super(); + } + + protected MastodonToolbarFragment(int layout){ + super(layout); + } + @Override public void onViewCreated(View view, Bundle savedInstanceState){ super.onViewCreated(view, savedInstanceState); diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/NotificationsFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/NotificationsFragment.java index fb589f119..b40565d1d 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/NotificationsFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/NotificationsFragment.java @@ -3,6 +3,7 @@ package org.joinmastodon.android.fragments; import android.app.Activity; import android.app.Fragment; import android.app.assist.AssistContent; +import android.content.res.Configuration; import android.os.Build; import android.os.Bundle; import android.view.LayoutInflater; @@ -24,6 +25,9 @@ import org.joinmastodon.android.E; import org.joinmastodon.android.GlobalUserPreferences; import org.joinmastodon.android.R; import org.joinmastodon.android.api.requests.accounts.GetFollowRequests; +import org.joinmastodon.android.api.requests.markers.SaveMarkers; +import org.joinmastodon.android.api.requests.notifications.PleromaMarkNotificationsRead; +import org.joinmastodon.android.api.session.AccountSessionManager; import org.joinmastodon.android.events.FollowRequestHandledEvent; import org.joinmastodon.android.model.Account; import org.joinmastodon.android.model.HeaderPaginationList; @@ -31,6 +35,8 @@ import org.joinmastodon.android.ui.SimpleViewHolder; import org.joinmastodon.android.ui.tabs.TabLayout; import org.joinmastodon.android.ui.tabs.TabLayoutMediator; import org.joinmastodon.android.ui.utils.UiUtils; +import org.joinmastodon.android.utils.ElevationOnScrollListener; +import org.joinmastodon.android.utils.ObjectIdComparator; import org.joinmastodon.android.utils.ProvidesAssistContent; import me.grishka.appkit.Nav; @@ -38,15 +44,19 @@ import me.grishka.appkit.api.Callback; import me.grishka.appkit.api.ErrorResponse; import me.grishka.appkit.fragments.BaseRecyclerFragment; import me.grishka.appkit.utils.V; +import me.grishka.appkit.views.FragmentRootLinearLayout; -public class NotificationsFragment extends MastodonToolbarFragment implements ScrollableToTop, ProvidesAssistContent { +public class NotificationsFragment extends MastodonToolbarFragment implements ScrollableToTop, ProvidesAssistContent, HasElevationOnScrollListener { - private TabLayout tabLayout; + TabLayout tabLayout; private ViewPager2 pager; private FrameLayout[] tabViews; + private View tabsDivider; private TabLayoutMediator tabLayoutMediator; - + String unreadMarker, realUnreadMarker; + private MenuItem markAllReadItem; private NotificationsListFragment allNotificationsFragment, mentionsFragment; + private ElevationOnScrollListener elevationOnScrollListener; private String accountID; @Override @@ -72,11 +82,19 @@ public class NotificationsFragment extends MastodonToolbarFragment implements Sc setTitle(R.string.notifications); } + @Override + public void onShown() { + super.onShown(); + unreadMarker=realUnreadMarker=AccountSessionManager.get(accountID).getLastKnownNotificationsMarker(); + } + @Override public void onCreateOptionsMenu(Menu menu, MenuInflater inflater){ inflater.inflate(R.menu.notifications, menu); menu.findItem(R.id.clear_notifications).setVisible(GlobalUserPreferences.enableDeleteNotifications); - UiUtils.enableOptionsMenuIcons(getActivity(), menu, R.id.follow_requests); + markAllReadItem=menu.findItem(R.id.mark_all_read); + updateMarkAllReadButton(); + UiUtils.enableOptionsMenuIcons(getActivity(), menu, R.id.follow_requests, R.id.mark_all_read); } @Override @@ -93,15 +111,40 @@ public class NotificationsFragment extends MastodonToolbarFragment implements Sc } }); return true; + } else if (item.getItemId() == R.id.mark_all_read) { + markAsRead(); + if (getCurrentFragment() instanceof NotificationsListFragment nlf) { + nlf.resetUnreadBackground(); + } + return true; } return false; } + void markAsRead(){ + if(allNotificationsFragment.getData().isEmpty()) return; + String id=allNotificationsFragment.getData().get(0).id; + if(ObjectIdComparator.INSTANCE.compare(id, realUnreadMarker)>0){ + new SaveMarkers(null, id).exec(accountID); + if (allNotificationsFragment.isInstanceAkkoma()) { + new PleromaMarkNotificationsRead(id).exec(accountID); + } + AccountSessionManager.get(accountID).setNotificationsMarker(id, true); + realUnreadMarker=id; + updateMarkAllReadButton(); + } + } + + public void updateMarkAllReadButton(){ + markAllReadItem.setVisible(!allNotificationsFragment.getData().isEmpty() && realUnreadMarker!=null && !realUnreadMarker.equals(allNotificationsFragment.getData().get(0).id)); + } + @Override public View onCreateContentView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState){ LinearLayout view=(LinearLayout) inflater.inflate(R.layout.fragment_notifications, container, false); tabLayout=view.findViewById(R.id.tabbar); + tabsDivider=view.findViewById(R.id.tabs_divider); pager=view.findViewById(R.id.pager); UiUtils.reduceSwipeSensitivity(pager); @@ -119,7 +162,7 @@ public class NotificationsFragment extends MastodonToolbarFragment implements Sc } tabLayout.setTabTextSize(V.dp(16)); - tabLayout.setTabTextColors(UiUtils.getThemeColor(getActivity(), R.attr.colorTabInactive), UiUtils.getThemeColor(getActivity(), android.R.attr.textColorPrimary)); + tabLayout.setTabTextColors(UiUtils.getThemeColor(getActivity(), R.attr.colorM3OnSurfaceVariant), UiUtils.getThemeColor(getActivity(), R.attr.colorM3Primary)); tabLayout.addOnTabSelectedListener(new TabLayout.OnTabSelectedListener() { @Override public void onTabSelected(TabLayout.Tab tab) {} @@ -139,6 +182,8 @@ public class NotificationsFragment extends MastodonToolbarFragment implements Sc pager.registerOnPageChangeCallback(new ViewPager2.OnPageChangeCallback(){ @Override public void onPageSelected(int position){ + if (elevationOnScrollListener != null && getCurrentFragment() instanceof IsOnTop f) + elevationOnScrollListener.handleScroll(getContext(), f.isOnTop()); if(position==0) return; Fragment _page=getFragmentForPage(position); @@ -176,7 +221,6 @@ public class NotificationsFragment extends MastodonToolbarFragment implements Sc case 1 -> R.string.mentions; default -> throw new IllegalStateException("Unexpected value: "+position); }); - tab.view.textView.setAllCaps(true); } }); tabLayoutMediator.attach(); @@ -184,6 +228,28 @@ public class NotificationsFragment extends MastodonToolbarFragment implements Sc return view; } + @Override + public void onViewCreated(View view, Bundle savedInstanceState) { + super.onViewCreated(view, savedInstanceState); + elevationOnScrollListener = new ElevationOnScrollListener((FragmentRootLinearLayout) view, getToolbar(), tabLayout); + elevationOnScrollListener.setDivider(tabsDivider); + } + + @Override + public void onConfigurationChanged(Configuration newConfig) { + super.onConfigurationChanged(newConfig); + if (elevationOnScrollListener == null) return; + elevationOnScrollListener.setViews(getToolbar(), tabLayout); + if (getCurrentFragment() instanceof IsOnTop f) { + elevationOnScrollListener.handleScroll(getContext(), f.isOnTop()); + } + } + + @Override + public ElevationOnScrollListener getElevationOnScrollListener() { + return elevationOnScrollListener; + } + public void refreshFollowRequestsBadge() { new GetFollowRequests(null, 1).setCallback(new Callback<>() { @Override @@ -228,6 +294,10 @@ public class NotificationsFragment extends MastodonToolbarFragment implements Sc }; } + public Fragment getCurrentFragment() { + return getFragmentForPage(pager.getCurrentItem()); + } + @Override public void onProvideAssistContent(AssistContent assistContent) { callFragmentToProvideAssistContent(getFragmentForPage(pager.getCurrentItem()), assistContent); diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/NotificationsListFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/NotificationsListFragment.java index a8dfc1c39..697f65f3f 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/NotificationsListFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/NotificationsListFragment.java @@ -1,6 +1,9 @@ package org.joinmastodon.android.fragments; import android.app.Activity; +import android.graphics.Canvas; +import android.graphics.Paint; +import android.graphics.Rect; import android.net.Uri; import android.os.Bundle; import android.text.TextUtils; @@ -10,50 +13,44 @@ import com.squareup.otto.Subscribe; import org.joinmastodon.android.E; import org.joinmastodon.android.R; -import org.joinmastodon.android.api.requests.markers.SaveMarkers; -import org.joinmastodon.android.api.requests.notifications.PleromaMarkNotificationsRead; +import org.joinmastodon.android.api.session.AccountSession; import org.joinmastodon.android.api.session.AccountSessionManager; -import org.joinmastodon.android.events.AllNotificationsSeenEvent; import org.joinmastodon.android.events.PollUpdatedEvent; import org.joinmastodon.android.events.RemoveAccountPostsEvent; import org.joinmastodon.android.events.StatusCountersUpdatedEvent; -import org.joinmastodon.android.model.Account; -import org.joinmastodon.android.model.CacheablePaginatedResponse; -import org.joinmastodon.android.model.Emoji; -import org.joinmastodon.android.model.Filter; -import org.joinmastodon.android.model.Instance; -import org.joinmastodon.android.model.Markers; import org.joinmastodon.android.model.Notification; +import org.joinmastodon.android.model.PaginatedResponse; import org.joinmastodon.android.model.Status; import org.joinmastodon.android.ui.displayitems.AccountCardStatusDisplayItem; import org.joinmastodon.android.ui.displayitems.ExtendedFooterStatusDisplayItem; import org.joinmastodon.android.ui.displayitems.FooterStatusDisplayItem; -import org.joinmastodon.android.ui.displayitems.HeaderStatusDisplayItem; +import org.joinmastodon.android.ui.displayitems.NotificationHeaderStatusDisplayItem; import org.joinmastodon.android.ui.displayitems.StatusDisplayItem; -import org.joinmastodon.android.ui.displayitems.TextStatusDisplayItem; -import org.joinmastodon.android.ui.text.HtmlParser; import org.joinmastodon.android.ui.utils.DiscoverInfoBannerHelper; import org.joinmastodon.android.ui.utils.InsetStatusItemDecoration; import org.joinmastodon.android.ui.utils.UiUtils; +import org.joinmastodon.android.utils.ElevationOnScrollListener; +import org.joinmastodon.android.utils.ObjectIdComparator; import org.parceler.Parcels; import java.util.ArrayList; -import java.util.Arrays; import java.util.Collections; import java.util.List; -import java.util.Set; import java.util.stream.Collectors; import java.util.stream.Stream; +import androidx.annotation.NonNull; import androidx.recyclerview.widget.RecyclerView; -import me.grishka.appkit.Nav; import me.grishka.appkit.api.SimpleCallback; +import me.grishka.appkit.utils.MergeRecyclerAdapter; +import me.grishka.appkit.views.FragmentRootLinearLayout; -public class NotificationsListFragment extends BaseStatusListFragment{ +public class NotificationsListFragment extends BaseStatusListFragment { private boolean onlyMentions; private boolean onlyPosts; private String maxID; - private final DiscoverInfoBannerHelper bannerHelper = new DiscoverInfoBannerHelper(DiscoverInfoBannerHelper.BannerType.POST_NOTIFICATIONS); + private boolean reloadingFromCache; + private DiscoverInfoBannerHelper bannerHelper; @Override protected boolean wantsComposeButton() { @@ -64,6 +61,13 @@ public class NotificationsListFragment extends BaseStatusListFragment buildDisplayItems(Notification n){ - Account reportTarget = n.report == null ? null : n.report.targetAccount == null ? null : - n.report.targetAccount; - Emoji emoji = new Emoji(); - if(n.emojiUrl!=null){ - emoji.shortcode=n.emoji.substring(1,n.emoji.length()-1); - emoji.url=n.emojiUrl; - emoji.staticUrl=n.emojiUrl; - emoji.visibleInPicker=false; + NotificationHeaderStatusDisplayItem titleItem; + if(n.type==Notification.Type.MENTION || n.type==Notification.Type.STATUS){ + titleItem=null; + }else{ + titleItem=new NotificationHeaderStatusDisplayItem(n.id, this, n, accountID); + } + if (n.type == Notification.Type.FOLLOW_REQUEST) { + ArrayList items = new ArrayList<>(); + items.add(titleItem); + items.add(new AccountCardStatusDisplayItem(n.id, this, n.account, n)); + return items; } - String extraText=switch(n.type){ - case FOLLOW -> getString(R.string.user_followed_you); - case FOLLOW_REQUEST -> getString(R.string.user_sent_follow_request); - case MENTION, STATUS -> null; - case REBLOG -> getString(R.string.notification_boosted); - case FAVORITE -> getString(R.string.user_favorited); - case POLL -> getString(R.string.poll_ended); - case UPDATE -> getString(R.string.sk_post_edited); - case SIGN_UP -> getString(R.string.sk_signed_up); - case REPORT -> getString(R.string.sk_reported); - case REACTION, PLEROMA_EMOJI_REACTION -> - n.emoji != null ? getString(R.string.sk_reacted_with, n.emoji) : getString(R.string.sk_reacted); - }; - HeaderStatusDisplayItem titleItem=extraText!=null ? new HeaderStatusDisplayItem(n.id, n.account, n.createdAt, this, accountID, n.status, n.emojiUrl!=null ? HtmlParser.parseCustomEmoji(extraText, Collections.singletonList(emoji)) : extraText, n, null) : null; if(n.status!=null){ - ArrayList items=StatusDisplayItem.buildItems(this, n.status, accountID, n, knownAccounts, titleItem!=null, titleItem==null, n, false, Filter.FilterContext.NOTIFICATIONS); + int flags=titleItem==null ? 0 : (StatusDisplayItem.FLAG_NO_FOOTER | StatusDisplayItem.FLAG_INSET); // | StatusDisplayItem.FLAG_NO_HEADER); + ArrayList items=StatusDisplayItem.buildItems(this, n.status, accountID, n, knownAccounts, null, flags); if(titleItem!=null) items.add(0, titleItem); return items; }else if(titleItem!=null){ - AccountCardStatusDisplayItem card=new AccountCardStatusDisplayItem(n.id, this, - reportTarget != null ? reportTarget : n.account, n); - TextStatusDisplayItem text = n.report != null && !TextUtils.isEmpty(n.report.comment) ? - new TextStatusDisplayItem(n.id, n.report.comment, this, - Status.ofFake(n.id, n.report.comment, n.createdAt), true) : - null; - return text == null ? Arrays.asList(titleItem, card) : Arrays.asList(titleItem, text, card); + return Collections.singletonList(titleItem); }else{ return Collections.emptyList(); } } - @Override protected void addAccountToKnown(Notification s){ if(!knownAccounts.containsKey(s.account.id)) @@ -144,52 +124,38 @@ public class NotificationsListFragment extends BaseStatusListFragment0 ? maxID : null, count, onlyMentions, onlyPosts, refreshing, new SimpleCallback<>(this){ + .getNotifications(offset>0 ? maxID : null, count, onlyMentions, onlyPosts, refreshing && !reloadingFromCache, new SimpleCallback<>(this){ @Override - public void onSuccess(CacheablePaginatedResponse> result){ - if (getActivity() == null) return; - if(refreshing) - relationships.clear(); + public void onSuccess(PaginatedResponse> result){ + if(getActivity()==null) + return; maxID=result.maxID; onDataLoaded(result.items.stream().filter(n->n.type!=null).collect(Collectors.toList()), !result.items.isEmpty()); - Set needRelationships=result.items.stream() - .filter(ntf->ntf.status==null && !relationships.containsKey(ntf.account.id)) - .map(ntf->ntf.account.id) - .collect(Collectors.toSet()); - loadRelationships(needRelationships); - - Markers markers = AccountSessionManager.getInstance().getAccount(accountID).markers; - if(offset==0 && !result.items.isEmpty() && !result.isFromCache() && markers != null && markers.notifications != null){ - E.post(new AllNotificationsSeenEvent()); - new SaveMarkers(null, result.items.get(0).id).exec(accountID); - AccountSessionManager.getInstance().getAccount(accountID).markers - .notifications.lastReadId = result.items.get(0).id; - AccountSessionManager.getInstance().writeAccountsFile(); - - if (isInstanceAkkoma()) { - new PleromaMarkNotificationsRead(result.items.get(0).id).exec(accountID); - } + reloadingFromCache=false; + if (getParentFragment() instanceof NotificationsFragment nf) { + nf.updateMarkAllReadButton(); } } }); } @Override - protected void onRelationshipsLoaded(){ - if(getActivity()==null) - return; - for(int i=0;i holder){ + String itemID=holder.getItemID(); + if(ObjectIdComparator.INSTANCE.compare(itemID, nf.unreadMarker)>0){ + parent.getDecoratedBoundsWithMargins(child, tmpRect); + c.drawRect(tmpRect, paint); + } + } + } + } + } + }, 0); + } + + @Override + protected List getViewsForElevationEffect(){ + if (getParentFragment() instanceof NotificationsFragment nf) { + ArrayList views=new ArrayList<>(super.getViewsForElevationEffect()); + views.add(nf.tabLayout); + return views; + } else { + return super.getViewsForElevationEffect(); + } + } + + @Override + public void onSaveInstanceState(Bundle outState){ + super.onSaveInstanceState(outState); + outState.putBoolean("onlyMentions", onlyMentions); + outState.putBoolean("onlyPosts", onlyPosts); } private Notification getNotificationByID(String id){ @@ -238,6 +247,7 @@ public class NotificationsListFragment extends BaseStatusListFragment=adapter.getItemCount()); + } + + void resetUnreadBackground(){ + if (getParentFragment() instanceof NotificationsFragment nf) { + nf.unreadMarker=nf.realUnreadMarker; + list.invalidate(); + } + } + + @Override + public void onRefresh(){ + super.onRefresh(); + if (getParentFragment() instanceof NotificationsFragment nf) { + if (!onlyMentions && !onlyPosts) nf.markAsRead(); + else AccountSessionManager.get(accountID).reloadNotificationsMarker(m->{ + nf.unreadMarker=nf.realUnreadMarker=m; + nf.updateMarkAllReadButton(); + }); + } + resetUnreadBackground(); + } + + @Override + protected RecyclerView.Adapter getAdapter(){ + if (bannerHelper == null) return super.getAdapter(); + MergeRecyclerAdapter adapter=new MergeRecyclerAdapter(); + bannerHelper.maybeAddBanner(list, adapter); + adapter.addAdapter(super.getAdapter()); + return adapter; + } + @Override public Uri getWebUri(Uri.Builder base) { return base.path(isInstanceAkkoma() diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/PinnableStatusListFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/PinnableStatusListFragment.java index 3b8481890..d1c6fcfbf 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/PinnableStatusListFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/PinnableStatusListFragment.java @@ -7,20 +7,21 @@ import android.view.MenuInflater; import android.view.MenuItem; import android.widget.Toast; -import org.joinmastodon.android.GlobalUserPreferences; import org.joinmastodon.android.R; +import org.joinmastodon.android.api.session.AccountLocalPreferences; +import org.joinmastodon.android.api.session.AccountSessionManager; import org.joinmastodon.android.model.TimelineDefinition; import java.util.ArrayList; import java.util.List; public abstract class PinnableStatusListFragment extends StatusListFragment { - protected List pinnedTimelines; + protected List timelines; @Override public void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); - pinnedTimelines = new ArrayList<>(GlobalUserPreferences.pinnedTimelines.getOrDefault(accountID, TimelineDefinition.getDefaultTimelines(accountID))); + timelines=new ArrayList<>(AccountSessionManager.get(accountID).getLocalPreferences().timelines); } @Override @@ -30,7 +31,7 @@ public abstract class PinnableStatusListFragment extends StatusListFragment { } protected boolean isPinned() { - return pinnedTimelines.contains(makeTimelineDefinition()); + return timelines.contains(makeTimelineDefinition()); } protected void updatePinButton(MenuItem pin) { @@ -57,11 +58,12 @@ public abstract class PinnableStatusListFragment extends StatusListFragment { getToolbar().performHapticFeedback(HapticFeedbackConstants.CONTEXT_CLICK); TimelineDefinition def = makeTimelineDefinition(); boolean pinned = isPinned(); - if (pinned) pinnedTimelines.remove(def); - else pinnedTimelines.add(def); + if (pinned) timelines.remove(def); + else timelines.add(def); Toast.makeText(getContext(), pinned ? R.string.sk_unpinned_timeline : R.string.sk_pinned_timeline, Toast.LENGTH_SHORT).show(); - GlobalUserPreferences.pinnedTimelines.put(accountID, pinnedTimelines); - GlobalUserPreferences.save(); + AccountLocalPreferences prefs=AccountSessionManager.get(accountID).getLocalPreferences(); + prefs.timelines=new ArrayList<>(timelines); + prefs.save(); updatePinButton(pin); } diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/PinnedPostsListFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/PinnedPostsListFragment.java index a091e5d42..53c44d36d 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/PinnedPostsListFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/PinnedPostsListFragment.java @@ -6,7 +6,7 @@ import android.os.Bundle; import org.joinmastodon.android.R; import org.joinmastodon.android.api.requests.accounts.GetAccountStatuses; import org.joinmastodon.android.model.Account; -import org.joinmastodon.android.model.Filter; +import org.joinmastodon.android.model.FilterContext; import org.joinmastodon.android.model.Status; import org.parceler.Parcels; @@ -41,8 +41,8 @@ public class PinnedPostsListFragment extends StatusListFragment{ } @Override - protected Filter.FilterContext getFilterContext() { - return Filter.FilterContext.ACCOUNT; + protected FilterContext getFilterContext() { + return FilterContext.ACCOUNT; } @Override diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/ProfileAboutFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/ProfileAboutFragment.java index 90751bded..e27369032 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/ProfileAboutFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/ProfileAboutFragment.java @@ -13,6 +13,7 @@ import android.view.View; import android.view.ViewGroup; import android.view.WindowInsets; import android.widget.EditText; +import android.widget.ImageView; import android.widget.TextView; import org.joinmastodon.android.R; @@ -43,16 +44,15 @@ import me.grishka.appkit.utils.V; import me.grishka.appkit.views.UsableRecyclerView; public class ProfileAboutFragment extends Fragment implements WindowInsetsAwareFragment{ - private static final int MAX_FIELDS=4; + static final int MAX_FIELDS=Integer.MAX_VALUE; public UsableRecyclerView list; private List fields=Collections.emptyList(); private AboutAdapter adapter; - private Paint dividerPaint=new Paint(); private boolean isInEditMode; private ItemTouchHelper dragHelper=new ItemTouchHelper(new ReorderCallback()); - private RecyclerView.ViewHolder draggedViewHolder; private ListImageLoaderWrapper imgLoader; + private boolean editDirty; public void setFields(List fields){ this.fields=fields; @@ -74,27 +74,8 @@ public class ProfileAboutFragment extends Fragment implements WindowInsetsAwareF list.setLayoutManager(new LinearLayoutManager(getActivity())); imgLoader=new ListImageLoaderWrapper(getActivity(), list, new RecyclerViewDelegate(list), null); list.setAdapter(adapter=new AboutAdapter()); - int pad=V.dp(16); - list.setPadding(pad, pad, pad, pad); + list.setPadding(0, V.dp(16), 0, 0); list.setClipToPadding(false); - dividerPaint.setStyle(Paint.Style.STROKE); - dividerPaint.setStrokeWidth(V.dp(1)); - dividerPaint.setColor(UiUtils.getThemeColor(getActivity(), R.attr.colorPollVoted)); - list.addItemDecoration(new RecyclerView.ItemDecoration(){ - @Override - public void onDrawOver(@NonNull Canvas c, @NonNull RecyclerView parent, @NonNull RecyclerView.State state){ - for(int i=0;i getFields(){ return fields; } + public boolean isEditDirty(){ + return editDirty; + } + @Override public void onApplyWindowInsets(WindowInsets insets){ if(Build.VERSION.SDK_INT>=29 && insets.getTappableElementInsets().bottom==0){ @@ -183,36 +169,25 @@ public class ProfileAboutFragment extends Fragment implements WindowInsetsAwareF } private abstract class BaseViewHolder extends BindableViewHolder{ - protected ShapeDrawable background=new ShapeDrawable(); - public BaseViewHolder(int layout){ super(getActivity(), layout, list); - background.getPaint().setColor(UiUtils.getThemeColor(getActivity(), R.attr.colorBackgroundLight)); - itemView.setBackground(background); } @Override public void onBind(AccountField item){ - boolean first=getAbsoluteAdapterPosition()==0, last=getAbsoluteAdapterPosition()==adapter.getItemCount()-1; - float radius=V.dp(10); - float[] rad=new float[8]; - if(first) - rad[0]=rad[1]=rad[2]=rad[3]=radius; - if(last) - rad[4]=rad[5]=rad[6]=rad[7]=radius; - background.setShape(new RoundRectShape(rad, null, null)); - itemView.invalidateOutline(); } } private class AboutViewHolder extends BaseViewHolder implements ImageLoaderViewHolder{ - private TextView title; - private LinkedTextView value; + private final TextView title; + private final LinkedTextView value; +// private final ImageView verifiedIcon; public AboutViewHolder(){ super(R.layout.item_profile_about); title=findViewById(R.id.title); value=findViewById(R.id.value); +// verifiedIcon=findViewById(R.id.verified_icon); } @Override @@ -220,20 +195,7 @@ public class ProfileAboutFragment extends Fragment implements WindowInsetsAwareF super.onBind(item); title.setText(item.parsedName); value.setText(item.parsedValue); - if(item.verifiedAt!=null){ - background.getPaint().setColor(UiUtils.isDarkTheme() ? 0xFF49595a : 0xFFd7e3da); - int textColor=UiUtils.isDarkTheme() ? 0xFF89bb9c : 0xFF5b8e63; - value.setTextColor(textColor); - value.setLinkTextColor(textColor); - Drawable check=getResources().getDrawable(R.drawable.ic_fluent_checkmark_24_regular, getActivity().getTheme()).mutate(); - check.setTint(textColor); - value.setCompoundDrawablesRelativeWithIntrinsicBounds(null, null, check, null); - }else{ - background.getPaint().setColor(UiUtils.getThemeColor(getActivity(), R.attr.colorBackgroundLight)); - value.setTextColor(UiUtils.getThemeColor(getActivity(), android.R.attr.textColorPrimary)); - value.setLinkTextColor(UiUtils.getThemeColor(getActivity(), android.R.attr.colorAccent)); - value.setCompoundDrawables(null, null, null, null); - } +// verifiedIcon.setVisibility(item.verifiedAt!=null ? View.VISIBLE : View.GONE); } @Override @@ -251,27 +213,38 @@ public class ProfileAboutFragment extends Fragment implements WindowInsetsAwareF } private class EditableAboutViewHolder extends BaseViewHolder{ - private EditText title; - private EditText value; + private final EditText title; + private final EditText value; + private boolean ignoreTextChange; public EditableAboutViewHolder(){ - super(R.layout.item_profile_about_editable); + super(R.layout.onboarding_profile_field); title=findViewById(R.id.title); - value=findViewById(R.id.value); + value=findViewById(R.id.content); findViewById(R.id.dragger_thingy).setOnLongClickListener(v->{ dragHelper.startDrag(this); return true; }); - title.addTextChangedListener(new SimpleTextWatcher(e->item.name=e.toString())); - value.addTextChangedListener(new SimpleTextWatcher(e->item.value=e.toString())); - findViewById(R.id.remove_row_btn).setOnClickListener(this::onRemoveRowClick); + title.addTextChangedListener(new SimpleTextWatcher(e->{ + item.name=e.toString(); + if(!ignoreTextChange) + editDirty=true; + })); + value.addTextChangedListener(new SimpleTextWatcher(e->{ + item.value=e.toString(); + if(!ignoreTextChange) + editDirty=true; + })); + findViewById(R.id.delete).setOnClickListener(this::onRemoveRowClick); } @Override public void onBind(AccountField item){ super.onBind(item); + ignoreTextChange=true; title.setText(item.name); value.setText(item.value); + ignoreTextChange=false; } private void onRemoveRowClick(View v){ @@ -323,8 +296,8 @@ public class ProfileAboutFragment extends Fragment implements WindowInsetsAwareF } } adapter.notifyItemMoved(fromPosition, toPosition); - ((BindableViewHolder)viewHolder).rebind(); - ((BindableViewHolder)target).rebind(); + ((BindableViewHolder)viewHolder).rebind(); + ((BindableViewHolder)target).rebind(); return true; } @@ -339,7 +312,6 @@ public class ProfileAboutFragment extends Fragment implements WindowInsetsAwareF if(actionState==ItemTouchHelper.ACTION_STATE_DRAG){ viewHolder.itemView.setTag(me.grishka.appkit.R.id.item_touch_helper_previous_elevation, viewHolder.itemView.getElevation()); // prevents the default behavior of changing elevation in onDraw() viewHolder.itemView.animate().translationZ(V.dp(1)).setDuration(200).setInterpolator(CubicBezierInterpolator.DEFAULT).start(); - draggedViewHolder=viewHolder; } } @@ -347,7 +319,6 @@ public class ProfileAboutFragment extends Fragment implements WindowInsetsAwareF public void clearView(@NonNull RecyclerView recyclerView, @NonNull RecyclerView.ViewHolder viewHolder){ super.clearView(recyclerView, viewHolder); viewHolder.itemView.animate().translationZ(0).setDuration(100).setInterpolator(CubicBezierInterpolator.DEFAULT).start(); - draggedViewHolder=null; } @Override diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/ProfileFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/ProfileFragment.java index a72eb02ac..e4c1fea95 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/ProfileFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/ProfileFragment.java @@ -1,5 +1,7 @@ package org.joinmastodon.android.fragments; +import static org.joinmastodon.android.fragments.ProfileAboutFragment.MAX_FIELDS; + import android.animation.Animator; import android.animation.AnimatorListenerAdapter; import android.animation.AnimatorSet; @@ -9,17 +11,23 @@ import android.app.Fragment; import android.app.assist.AssistContent; import android.content.Intent; import android.content.res.Configuration; +import android.content.res.TypedArray; import android.graphics.Color; import android.graphics.Outline; import android.graphics.drawable.ColorDrawable; import android.graphics.drawable.Drawable; import android.graphics.drawable.GradientDrawable; +import android.graphics.drawable.LayerDrawable; import android.net.Uri; import android.os.Build; import android.os.Bundle; import android.text.SpannableStringBuilder; import android.text.TextUtils; import android.text.style.ImageSpan; +import android.transition.ChangeBounds; +import android.transition.Fade; +import android.transition.TransitionManager; +import android.transition.TransitionSet; import android.view.Gravity; import android.view.LayoutInflater; import android.view.Menu; @@ -32,38 +40,40 @@ import android.view.ViewOutlineProvider; import android.view.ViewTreeObserver; import android.view.WindowInsets; import android.view.inputmethod.InputMethodManager; -import android.widget.Button; import android.widget.EditText; import android.widget.FrameLayout; import android.widget.ImageButton; import android.widget.ImageView; +import android.widget.LinearLayout; import android.widget.ProgressBar; -import android.widget.RelativeLayout; import android.widget.TextView; import android.widget.Toast; import android.widget.Toolbar; import org.joinmastodon.android.GlobalUserPreferences; import org.joinmastodon.android.R; -import org.joinmastodon.android.api.MastodonErrorResponse; import org.joinmastodon.android.api.requests.accounts.GetAccountByID; import org.joinmastodon.android.api.requests.accounts.GetAccountRelationships; import org.joinmastodon.android.api.requests.accounts.GetAccountStatuses; import org.joinmastodon.android.api.requests.accounts.GetOwnAccount; import org.joinmastodon.android.api.requests.accounts.SetAccountFollowed; import org.joinmastodon.android.api.requests.accounts.UpdateAccountCredentials; +import org.joinmastodon.android.api.requests.instance.GetInstance; import org.joinmastodon.android.api.session.AccountSessionManager; import org.joinmastodon.android.fragments.account_list.FollowerListFragment; import org.joinmastodon.android.fragments.account_list.FollowingListFragment; import org.joinmastodon.android.fragments.report.ReportReasonChoiceFragment; +import org.joinmastodon.android.fragments.settings.SettingsServerFragment; import org.joinmastodon.android.model.Account; import org.joinmastodon.android.model.AccountField; import org.joinmastodon.android.model.Attachment; +import org.joinmastodon.android.model.Instance; import org.joinmastodon.android.model.Relationship; import org.joinmastodon.android.ui.BetterItemAnimator; +import org.joinmastodon.android.ui.M3AlertDialogBuilder; +import org.joinmastodon.android.ui.OutlineProviders; import org.joinmastodon.android.ui.SimpleViewHolder; import org.joinmastodon.android.ui.SingleImagePhotoViewerListener; -import org.joinmastodon.android.ui.drawables.CoverOverlayGradientDrawable; import org.joinmastodon.android.ui.photoviewer.PhotoViewer; import org.joinmastodon.android.ui.tabs.TabLayout; import org.joinmastodon.android.ui.tabs.TabLayoutMediator; @@ -72,9 +82,11 @@ import org.joinmastodon.android.ui.text.HtmlParser; import org.joinmastodon.android.ui.utils.SimpleTextWatcher; import org.joinmastodon.android.ui.utils.UiUtils; import org.joinmastodon.android.ui.views.CoverImageView; +import org.joinmastodon.android.ui.views.CustomDrawingOrderLinearLayout; import org.joinmastodon.android.ui.views.LinkedTextView; import org.joinmastodon.android.ui.views.NestedRecyclerScrollView; import org.joinmastodon.android.ui.views.ProgressBarButton; +import org.joinmastodon.android.utils.ElevationOnScrollListener; import org.joinmastodon.android.utils.ProvidesAssistContent; import org.parceler.Parcels; @@ -93,7 +105,6 @@ import androidx.recyclerview.widget.LinearLayoutManager; import androidx.recyclerview.widget.RecyclerView; import androidx.swiperefreshlayout.widget.SwipeRefreshLayout; import androidx.viewpager2.widget.ViewPager2; - import me.grishka.appkit.Nav; import me.grishka.appkit.api.Callback; import me.grishka.appkit.api.ErrorResponse; @@ -111,6 +122,7 @@ import me.grishka.appkit.imageloader.requests.UrlImageLoaderRequest; import me.grishka.appkit.utils.BindableViewHolder; import me.grishka.appkit.utils.CubicBezierInterpolator; import me.grishka.appkit.utils.V; +import me.grishka.appkit.views.FragmentRootLinearLayout; import me.grishka.appkit.views.UsableRecyclerView; public class ProfileFragment extends LoaderFragment implements OnBackPressedListener, ScrollableToTop, HasFab, ProvidesAssistContent.ProvidesWebUri { @@ -119,35 +131,36 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList private ImageView avatar; private CoverImageView cover; - private View avatarBorder, nameWrap; - private TextView name, username, bio, followersCount, followersLabel, followingCount, followingLabel, postsCount, postsLabel; + private View avatarBorder; + private TextView name, username, bio, followersCount, followersLabel, followingCount, followingLabel; private ProgressBarButton actionButton, notifyButton; private ViewPager2 pager; private NestedRecyclerScrollView scrollView; private AccountTimelineFragment postsFragment, postsWithRepliesFragment, mediaFragment; private PinnedPostsListFragment pinnedPostsFragment; -// private ProfileAboutFragment aboutFragment; private TabLayout tabbar; private SwipeRefreshLayout refreshLayout; - private CoverOverlayGradientDrawable coverGradient=new CoverOverlayGradientDrawable(); - private float titleTransY; - private View postsBtn, followersBtn, followingBtn, profileCounters; + private View followersBtn, followingBtn; private EditText nameEdit, bioEdit; private ProgressBar actionProgress, notifyProgress; private FrameLayout[] tabViews; private TabLayoutMediator tabLayoutMediator; private TextView followsYouView; private ViewGroup rolesView; + private LinearLayout countersLayout; + private View nameEditWrap, bioEditWrap; + private View tabsDivider; + private View actionButtonWrap; + private CustomDrawingOrderLinearLayout scrollableContent; private Account account, remoteAccount; private String accountID; private String domain; private Relationship relationship; - private int statusBarHeight; private boolean isOwnProfile; - private ArrayList fields=new ArrayList<>(); + private List fields=new ArrayList<>(); - private boolean isInEditMode; + private boolean isInEditMode, editDirty; private Uri editNewAvatar, editNewCover; private String profileAccountID; private boolean refreshing; @@ -155,21 +168,19 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList private WindowInsets childInsets; private PhotoViewer currentPhotoViewer; private boolean editModeLoading; - - private int maxFields = 4; + private ElevationOnScrollListener onScrollListener; + private Drawable tabsColorBackground; + private boolean tabBarIsAtTop; + private Animator tabBarColorAnim; + private MenuItem editSaveMenuItem; + private boolean savingEdits; // from ProfileAboutFragment public UsableRecyclerView list; - private List metadataListData=Collections.emptyList(); - private MetadataAdapter adapter; + private AboutAdapter adapter; private ItemTouchHelper dragHelper=new ItemTouchHelper(new ReorderCallback()); - private RecyclerView.ViewHolder draggedViewHolder; private ListImageLoaderWrapper imgLoader; - public ProfileFragment(){ - super(R.layout.loader_fragment_overlay_toolbar); - } - @Override public void onCreate(Bundle savedInstanceState){ super.onCreate(savedInstanceState); @@ -189,8 +200,6 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList loaded=true; if(!isOwnProfile) loadRelationship(); - else if (isInstanceAkkoma() && getInstance().isPresent()) - maxFields = getInstance().get().pleroma.metadata.fieldsLimits.maxFields; }else{ profileAccountID=getArguments().getString("profileAccountID"); if(!getArguments().getBoolean("noAutoLoad", false)) @@ -217,19 +226,14 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList cover=content.findViewById(R.id.cover); avatarBorder=content.findViewById(R.id.avatar_border); name=content.findViewById(R.id.name); - nameWrap=content.findViewById(R.id.name_wrap); username=content.findViewById(R.id.username); bio=content.findViewById(R.id.bio); - profileCounters=content.findViewById(R.id.profile_counters); followersCount=content.findViewById(R.id.followers_count); followersLabel=content.findViewById(R.id.followers_label); followersBtn=content.findViewById(R.id.followers_btn); followingCount=content.findViewById(R.id.following_count); followingLabel=content.findViewById(R.id.following_label); followingBtn=content.findViewById(R.id.following_btn); - postsCount=content.findViewById(R.id.posts_count); - postsLabel=content.findViewById(R.id.posts_label); - postsBtn=content.findViewById(R.id.posts_btn); actionButton=content.findViewById(R.id.profile_action_btn); notifyButton=content.findViewById(R.id.notify_btn); pager=content.findViewById(R.id.pager); @@ -238,27 +242,26 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList refreshLayout=content.findViewById(R.id.refresh_layout); nameEdit=content.findViewById(R.id.name_edit); bioEdit=content.findViewById(R.id.bio_edit); + nameEditWrap=content.findViewById(R.id.name_edit_wrap); + bioEditWrap=content.findViewById(R.id.bio_edit_wrap); actionProgress=content.findViewById(R.id.action_progress); notifyProgress=content.findViewById(R.id.notify_progress); fab=content.findViewById(R.id.fab); followsYouView=content.findViewById(R.id.follows_you); + countersLayout=content.findViewById(R.id.profile_counters); + tabsDivider=content.findViewById(R.id.tabs_divider); + actionButtonWrap=content.findViewById(R.id.profile_action_btn_wrap); + scrollableContent=content.findViewById(R.id.scrollable_content); list=content.findViewById(R.id.metadata); rolesView=content.findViewById(R.id.roles); - avatar.setOutlineProvider(new ViewOutlineProvider(){ - @Override - public void getOutline(View view, Outline outline){ - outline.setRoundRect(0, 0, view.getWidth(), view.getHeight(), V.dp(25)); - } - }); + avatar.setOutlineProvider(OutlineProviders.roundedRect(24)); avatar.setClipToOutline(true); FrameLayout sizeWrapper=new FrameLayout(getActivity()){ @Override protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec){ - Toolbar toolbar=getToolbar(); - pager.getLayoutParams().height=MeasureSpec.getSize(heightMeasureSpec)-getPaddingTop()-getPaddingBottom()-toolbar.getLayoutParams().height-statusBarHeight-V.dp(38); - coverGradient.setTopPadding(statusBarHeight+toolbar.getLayoutParams().height); + pager.getLayoutParams().height=MeasureSpec.getSize(heightMeasureSpec)-getPaddingTop()-getPaddingBottom()-V.dp(48); super.onMeasure(widthMeasureSpec, heightMeasureSpec); } }; @@ -271,7 +274,6 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList case 1 -> R.id.profile_posts_with_replies; case 2 -> R.id.profile_pinned_posts; case 3 -> R.id.profile_media; - case 4 -> R.id.profile_about; default -> throw new IllegalStateException("Unexpected value: "+i); }); tabView.setVisibility(View.GONE); @@ -286,11 +288,12 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList pager.getLayoutParams().height=getResources().getDisplayMetrics().heightPixels; scrollView.setScrollableChildSupplier(this::getScrollableRecyclerView); + scrollView.getViewTreeObserver().addOnGlobalLayoutListener(this::updateMetadataHeight); sizeWrapper.addView(content); - tabbar.setTabTextColors(UiUtils.getThemeColor(getActivity(), android.R.attr.textColorSecondary), UiUtils.getThemeColor(getActivity(), android.R.attr.textColorPrimary)); - tabbar.setTabTextSize(V.dp(16)); + tabbar.setTabTextColors(UiUtils.getThemeColor(getActivity(), R.attr.colorM3OnSurfaceVariant), UiUtils.getThemeColor(getActivity(), R.attr.colorM3Primary)); + tabbar.setTabTextSize(V.dp(14)); tabLayoutMediator=new TabLayoutMediator(tabbar, pager, new TabLayoutMediator.TabConfigurationStrategy(){ @Override public void onConfigureTab(@NonNull TabLayout.Tab tab, int position){ @@ -299,13 +302,12 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList case 1 -> R.string.posts_and_replies; case 2 -> R.string.sk_pinned_posts; case 3 -> R.string.media; - case 4 -> R.string.profile_about; default -> throw new IllegalStateException(); }); + if (position == 4) tab.view.setVisibility(View.GONE); } }); - cover.setForeground(coverGradient); cover.setOutlineProvider(new ViewOutlineProvider(){ @Override public void getOutline(View view, Outline outline){ @@ -333,6 +335,31 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList followersBtn.setOnClickListener(this::onFollowersOrFollowingClick); followingBtn.setOnClickListener(this::onFollowersOrFollowingClick); + username.setOnClickListener(v->{ + try { + new GetInstance() + .setCallback(new Callback<>(){ + @Override + public void onSuccess(Instance result){ + Bundle args = new Bundle(); + args.putParcelable("instance", Parcels.wrap(result)); + args.putString("account", accountID); + Nav.go(getActivity(), SettingsServerFragment.class, args); + } + + @Override + public void onError(ErrorResponse error){ + error.showToast(getContext()); + } + }) + .wrapProgress((Activity) getContext(), R.string.loading, true) + .execRemote(Uri.parse(account.url).getHost()); + } catch (NullPointerException ignored) { + // maybe the url was malformed? + Toast.makeText(getContext(), R.string.error, Toast.LENGTH_SHORT); + } + }); + username.setOnLongClickListener(v->{ String usernameString=account.acct; if(!usernameString.contains("@")){ @@ -347,9 +374,25 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList list.setDrawSelectorOnTop(true); list.setLayoutManager(new LinearLayoutManager(getActivity())); imgLoader=new ListImageLoaderWrapper(getActivity(), list, new RecyclerViewDelegate(list), null); - list.setAdapter(adapter=new MetadataAdapter()); + list.setAdapter(adapter=new AboutAdapter()); list.setClipToPadding(false); + scrollableContent.setDrawingOrderCallback((count, pos)->{ + // The header is the first child, draw it last to overlap everything for the photo viewer transition to look nice + if(pos==count-1) + return 0; + // Offset the order of other child views to compensate + return pos+1; + }); + + int colorBackground=UiUtils.getThemeColor(getActivity(), R.attr.colorM3Background); + int colorPrimary=UiUtils.getThemeColor(getActivity(), R.attr.colorM3Primary); + refreshLayout.setProgressBackgroundColorSchemeColor(UiUtils.alphaBlendColors(colorBackground, colorPrimary, 0.11f)); + refreshLayout.setColorSchemeColors(colorPrimary); + + nameEdit.addTextChangedListener(new SimpleTextWatcher(e->editDirty=true)); + bioEdit.addTextChangedListener(new SimpleTextWatcher(e->editDirty=true)); + return sizeWrapper; } @@ -430,7 +473,6 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList args.putBoolean("__is_tab", true); pinnedPostsFragment=new PinnedPostsListFragment(); pinnedPostsFragment.setArguments(args); -// aboutFragment=new ProfileAboutFragment(); setFields(fields); } pager.getAdapter().notifyDataSetChanged(); @@ -449,10 +491,8 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList pager.registerOnPageChangeCallback(new ViewPager2.OnPageChangeCallback(){ @Override public void onPageSelected(int position){ - if(position==0) - return; Fragment _page=getFragmentForPage(position); - if(_page instanceof BaseRecyclerFragment page){ + if(_page instanceof BaseRecyclerFragment page && page.isAdded()){ if(!page.loaded && !page.isDataLoading()) page.loadData(); } @@ -460,6 +500,8 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList @Override public void onPageScrollStateChanged(int state){ + if(isInEditMode) + return; refreshLayout.setEnabled(state!=ViewPager2.SCROLL_STATE_DRAGGING); } }); @@ -467,18 +509,24 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList } }); - scrollView.setOnScrollChangeListener(this::onScrollChanged); - titleTransY=getToolbar().getLayoutParams().height; - if(toolbarTitleView!=null){ - toolbarTitleView.setTranslationY(titleTransY); - toolbarSubtitleView.setTranslationY(titleTransY); - } - RecyclerFragment.setRefreshLayoutColors(refreshLayout); - } + tabsColorBackground=((LayerDrawable)tabbar.getBackground()).findDrawableByLayerId(R.id.color_overlay); - @Override - public void onDestroyView(){ - super.onDestroyView(); + onScrollListener=new ElevationOnScrollListener((FragmentRootLinearLayout) view, getToolbar()); + scrollView.setOnScrollChangeListener(this::onScrollChanged); + scrollView.getViewTreeObserver().addOnPreDrawListener(new ViewTreeObserver.OnPreDrawListener(){ + @Override + public boolean onPreDraw(){ + scrollView.getViewTreeObserver().removeOnPreDrawListener(this); + + tabBarIsAtTop=!scrollView.canScrollVertically(1) && scrollView.getHeight()>0; + if (UiUtils.isTrueBlackTheme()) tabBarIsAtTop=false; + tabsColorBackground.setAlpha(tabBarIsAtTop ? 20 : 0); + tabbar.setTranslationZ(tabBarIsAtTop ? V.dp(3) : 0); + tabsDivider.setAlpha(tabBarIsAtTop ? 0 : 1); + + return true; + } + }); } @Override @@ -489,21 +537,18 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList @Override public void onApplyWindowInsets(WindowInsets insets){ - statusBarHeight=insets.getSystemWindowInsetTop(); if(contentView!=null){ - ((ViewGroup.MarginLayoutParams) getToolbar().getLayoutParams()).topMargin=statusBarHeight; - refreshLayout.setProgressViewEndTarget(true, statusBarHeight+refreshLayout.getProgressCircleDiameter()+V.dp(24)); if(Build.VERSION.SDK_INT>=29 && insets.getTappableElementInsets().bottom==0){ int insetBottom=insets.getSystemWindowInsetBottom(); childInsets=insets.inset(insets.getSystemWindowInsetLeft(), insets.getSystemWindowInsetTop(), insets.getSystemWindowInsetRight(), 0); - ((ViewGroup.MarginLayoutParams) fab.getLayoutParams()).bottomMargin=V.dp(24)+insetBottom; + ((ViewGroup.MarginLayoutParams) fab.getLayoutParams()).bottomMargin=V.dp(16)+insetBottom; applyChildWindowInsets(); insets=insets.inset(0, 0, 0, insetBottom); }else{ - ((ViewGroup.MarginLayoutParams) fab.getLayoutParams()).bottomMargin=V.dp(24); + ((ViewGroup.MarginLayoutParams) fab.getLayoutParams()).bottomMargin=V.dp(16); } } - super.onApplyWindowInsets(insets.replaceSystemWindowInsets(insets.getSystemWindowInsetLeft(), 0, insets.getSystemWindowInsetRight(), insets.getSystemWindowInsetBottom())); + super.onApplyWindowInsets(insets); } private void applyChildWindowInsets(){ @@ -521,9 +566,10 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList ViewImageLoader.load(avatar, null, new UrlImageLoaderRequest(GlobalUserPreferences.playGifs ? account.avatar : account.avatarStatic, V.dp(100), V.dp(100))); ViewImageLoader.load(cover, null, new UrlImageLoaderRequest(GlobalUserPreferences.playGifs ? account.header : account.headerStatic, 1000, 1000)); SpannableStringBuilder ssb=new SpannableStringBuilder(account.displayName); + if(AccountSessionManager.get(accountID).getLocalPreferences().customEmojiInNames) HtmlParser.parseCustomEmoji(ssb, account.emojis); - name.setText(ssb); - setTitle(ssb); + name.setText(ssb); + setTitle(ssb); if (account.roles != null && !account.roles.isEmpty()) { rolesView.setVisibility(View.VISIBLE); @@ -532,9 +578,10 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList for (Account.Role role : account.roles) { TextView roleText = new TextView(getActivity(), null, 0, R.style.role_label); roleText.setText(role.name); + roleText.setGravity(Gravity.CENTER_VERTICAL); if (!TextUtils.isEmpty(role.color) && role.color.startsWith("#")) try { GradientDrawable bg = (GradientDrawable) roleText.getBackground().mutate(); - bg.setStroke(V.dp(2), Color.parseColor(role.color)); + bg.setStroke(V.dp(1), Color.parseColor(role.color)); } catch (Exception ignored) {} rolesView.addView(roleText); } @@ -567,13 +614,13 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList } followersCount.setText(UiUtils.abbreviateNumber(account.followersCount)); followingCount.setText(UiUtils.abbreviateNumber(account.followingCount)); - postsCount.setText(UiUtils.abbreviateNumber(account.statusesCount)); followersLabel.setText(getResources().getQuantityString(R.plurals.followers, (int)Math.min(999, account.followersCount))); followingLabel.setText(getResources().getQuantityString(R.plurals.following, (int)Math.min(999, account.followingCount))); - postsLabel.setText(getResources().getQuantityString(R.plurals.posts, (int)Math.min(999, account.statusesCount))); - + if (account.followersCount < 0) followersBtn.setVisibility(View.GONE); if (account.followingCount < 0) followingBtn.setVisibility(View.GONE); + if (account.followersCount < 0 || account.followingCount < 0) + countersLayout.findViewById(R.id.profile_counters_separator).setVisibility(View.GONE); UiUtils.loadCustomEmojiInTextView(name); UiUtils.loadCustomEmojiInTextView(bio); @@ -581,6 +628,12 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList notifyButton.setVisibility(View.GONE); if(AccountSessionManager.getInstance().isSelf(accountID, account)){ actionButton.setText(R.string.edit_profile); + TypedArray ta=actionButton.getContext().obtainStyledAttributes(R.style.Widget_Mastodon_M3_Button_Tonal, new int[]{android.R.attr.background}); + actionButton.setBackground(ta.getDrawable(0)); + ta.recycle(); + ta=actionButton.getContext().obtainStyledAttributes(R.style.Widget_Mastodon_M3_Button_Tonal, new int[]{android.R.attr.textColor}); + actionButton.setTextColor(ta.getColorStateList(0)); + ta.recycle(); }else{ actionButton.setVisibility(View.GONE); } @@ -615,18 +668,11 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList } private void updateToolbar(){ - getToolbar().setBackgroundColor(0); - if(toolbarTitleView!=null){ - toolbarTitleView.setTranslationY(titleTransY); - toolbarSubtitleView.setTranslationY(titleTransY); - } getToolbar().setOnClickListener(v->scrollToTop()); getToolbar().setNavigationContentDescription(R.string.back); - } - - @Override - public boolean wantsLightStatusBar(){ - return false; + if(onScrollListener!=null){ + onScrollListener.setViews(getToolbar()); + } } @Override @@ -637,16 +683,10 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList @Override public void onCreateOptionsMenu(Menu menu, MenuInflater inflater){ if(isOwnProfile && isInEditMode){ - Button cancelButton=new Button(getActivity(), null, 0, R.style.Widget_Mastodon_Button_Secondary_LightOnDark); - cancelButton.setText(R.string.cancel); - cancelButton.setOnClickListener(v->exitEditMode()); - FrameLayout wrap=new FrameLayout(getActivity()); - wrap.addView(cancelButton, new FrameLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT, Gravity.TOP|Gravity.LEFT)); - wrap.setPadding(V.dp(16), V.dp(4), V.dp(16), V.dp(8)); - wrap.setClipToPadding(false); - MenuItem item=menu.add(R.string.cancel); - item.setActionView(wrap); - item.setShowAsAction(MenuItem.SHOW_AS_ACTION_ALWAYS); + editSaveMenuItem=menu.add(0, R.id.save, 0, R.string.save_changes); + editSaveMenuItem.setIcon(R.drawable.ic_fluent_save_24_regular); + editSaveMenuItem.setShowAsAction(MenuItem.SHOW_AS_ACTION_ALWAYS); + editSaveMenuItem.setVisible(!isActionButtonInView()); return; } if(relationship==null && !isOwnProfile) @@ -663,7 +703,7 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList getActivity(), s.getID(), account.url, false )); } - menu.findItem(R.id.share).setTitle(getString(R.string.share_user, account.getShortUsername())); + menu.findItem(R.id.share).setTitle(R.string.share_user); if(isOwnProfile) { if (isInstancePixelfed()) menu.findItem(R.id.scheduled).setVisible(false); return; @@ -673,18 +713,17 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList mute.setTitle(getString(relationship.muting ? R.string.unmute_user : R.string.mute_user, account.getShortUsername())); mute.setIcon(relationship.muting ? R.drawable.ic_fluent_speaker_0_24_regular : R.drawable.ic_fluent_speaker_off_24_regular); UiUtils.insetPopupMenuIcon(getContext(), mute); - menu.findItem(R.id.block).setTitle(getString(relationship.blocking ? R.string.unblock_user : R.string.block_user, account.getShortUsername())); menu.findItem(R.id.report).setTitle(getString(R.string.report_user, account.getShortUsername())); menu.findItem(R.id.manage_user_lists).setVisible(relationship.following); menu.findItem(R.id.soft_block).setVisible(relationship.followedBy && !relationship.following); - if(relationship.following) { + if (relationship.following) { MenuItem hideBoosts = menu.findItem(R.id.hide_boosts); hideBoosts.setTitle(getString(relationship.showingReblogs ? R.string.hide_boosts_from_user : R.string.show_boosts_from_user, account.getShortUsername())); hideBoosts.setIcon(relationship.showingReblogs ? R.drawable.ic_fluent_arrow_repeat_all_off_24_regular : R.drawable.ic_fluent_arrow_repeat_all_24_regular); UiUtils.insetPopupMenuIcon(getContext(), hideBoosts); menu.findItem(R.id.manage_user_lists).setTitle(getString(R.string.sk_lists_with_user, account.getShortUsername())); - }else { + } else { menu.findItem(R.id.hide_boosts).setVisible(false); } if(!account.isLocal()) @@ -696,8 +735,8 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList @Override public boolean onOptionsItemSelected(MenuItem item){ int id=item.getItemId(); - if(id==R.id.share) { - Intent intent = new Intent(Intent.ACTION_SEND); + if(id==R.id.share){ + Intent intent=new Intent(Intent.ACTION_SEND); intent.setType("text/plain"); intent.putExtra(Intent.EXTRA_TEXT, account.url); startActivity(Intent.createChooser(intent, item.getTitle())); @@ -711,6 +750,7 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList Bundle args=new Bundle(); args.putString("account", accountID); args.putParcelable("reportAccount", Parcels.wrap(account)); + args.putParcelable("relationship", Parcels.wrap(relationship)); Nav.go(getActivity(), ReportReasonChoiceFragment.class, args); }else if(id==R.id.open_in_browser){ UiUtils.launchWebBrowser(getActivity(), account.url); @@ -758,15 +798,13 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList Bundle args=new Bundle(); args.putString("account", accountID); Nav.go(getActivity(), ScheduledStatusListFragment.class, args); + }else if(id==R.id.save){ + if(isInEditMode) + saveAndExitEditMode(); } return true; } - @Override - protected int getToolbarResource(){ - return R.layout.profile_toolbar; - } - private void loadRelationship(){ new GetAccountRelationships(Collections.singletonList(account.id)) .setCallback(new Callback<>(){ @@ -791,8 +829,7 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList invalidateOptionsMenu(); actionButton.setVisibility(View.VISIBLE); notifyButton.setVisibility(relationship.following ? View.VISIBLE : View.GONE); - UiUtils.setRelationshipToActionButton(relationship, actionButton); - UiUtils.setRelationshipToActionButton(relationship, notifyButton, true); + UiUtils.setRelationshipToActionButtonM3(relationship, actionButton); actionProgress.setIndeterminateTintList(actionButton.getTextColors()); notifyProgress.setIndeterminateTintList(notifyButton.getTextColors()); followsYouView.setVisibility(relationship.followedBy ? View.VISIBLE : View.GONE); @@ -821,35 +858,59 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList } private void onScrollChanged(View v, int scrollX, int scrollY, int oldScrollX, int oldScrollY){ - int topBarsH=getToolbar().getHeight()+statusBarHeight; - if(scrollY>avatarBorder.getTop()-topBarsH){ - float avaAlpha=Math.max(1f-((scrollY-(avatarBorder.getTop()-topBarsH))/(float)V.dp(38)), 0f); - avatarBorder.setAlpha(avaAlpha); - }else{ - avatarBorder.setAlpha(1f); - } - if(scrollY>cover.getHeight()-topBarsH){ - cover.setTranslationY(scrollY-(cover.getHeight()-topBarsH)); + if(scrollY>cover.getHeight()){ + cover.setTranslationY(scrollY-(cover.getHeight())); cover.setTranslationZ(V.dp(10)); - cover.setTransform(cover.getHeight()/2f-topBarsH/2f, 1f); + cover.setTransform(cover.getHeight()/2f); }else{ cover.setTranslationY(0f); cover.setTranslationZ(0f); - cover.setTransform(scrollY/2f, 1f); + cover.setTransform(scrollY/2f); } - coverGradient.setTopOffset(scrollY); cover.invalidate(); - titleTransY=getToolbar().getHeight(); - if(scrollY>nameWrap.getTop()-topBarsH){ - titleTransY=Math.max(0f, titleTransY-(scrollY-(nameWrap.getTop()-topBarsH))); - } - if(toolbarTitleView!=null){ - toolbarTitleView.setTranslationY(titleTransY); - toolbarSubtitleView.setTranslationY(titleTransY); - } if(currentPhotoViewer!=null){ currentPhotoViewer.offsetView(0, oldScrollY-scrollY); } + onScrollListener.onScrollChange(v, scrollX, scrollY, oldScrollX, oldScrollY); + + boolean newTabBarIsAtTop=!scrollView.canScrollVertically(1); + if(newTabBarIsAtTop!=tabBarIsAtTop){ + if(UiUtils.isTrueBlackTheme()) newTabBarIsAtTop=false; + tabBarIsAtTop=newTabBarIsAtTop; + + if(tabBarIsAtTop){ + // ScrollView would sometimes leave 1 pixel unscrolled, force it into the correct scrollY + int maxY=scrollView.getChildAt(0).getHeight()-scrollView.getHeight(); + if(scrollView.getScrollY()!=maxY) + scrollView.scrollTo(0, maxY); + } + + if(tabBarColorAnim!=null) + tabBarColorAnim.cancel(); + AnimatorSet set=new AnimatorSet(); + set.playTogether( + ObjectAnimator.ofInt(tabsColorBackground, "alpha", tabBarIsAtTop ? 20 : 0), + ObjectAnimator.ofFloat(tabbar, View.TRANSLATION_Z, tabBarIsAtTop ? V.dp(3) : 0), + ObjectAnimator.ofFloat(getToolbar(), View.TRANSLATION_Z, tabBarIsAtTop ? 0 : V.dp(3)), + ObjectAnimator.ofFloat(tabsDivider, View.ALPHA, tabBarIsAtTop ? 0 : 1) + ); + set.setDuration(150); + set.setInterpolator(CubicBezierInterpolator.DEFAULT); + set.addListener(new AnimatorListenerAdapter(){ + @Override + public void onAnimationEnd(Animator animation){ + tabBarColorAnim=null; + } + }); + tabBarColorAnim=set; + set.start(); + } + if(isInEditMode && editSaveMenuItem!=null){ + boolean buttonInView=isActionButtonInView(); + if(buttonInView==editSaveMenuItem.isVisible()){ + editSaveMenuItem.setVisible(!buttonInView); + } + } } private Fragment getFragmentForPage(int page){ @@ -858,13 +919,13 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList case 1 -> postsWithRepliesFragment; case 2 -> pinnedPostsFragment; case 3 -> mediaFragment; -// case 4 -> aboutFragment; default -> throw new IllegalStateException(); }; } private RecyclerView getScrollableRecyclerView(){ - return getFragmentForPage(pager.getCurrentItem()).getView().findViewById(R.id.list); + return isInEditMode ? list : + getFragmentForPage(pager.getCurrentItem()).getView().findViewById(R.id.list); } private void onActionButtonClick(View v){ @@ -906,12 +967,16 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList private void setActionProgressVisible(boolean visible){ actionButton.setTextVisible(!visible); actionProgress.setVisibility(visible ? View.VISIBLE : View.GONE); + if(visible) + actionProgress.setIndeterminateTintList(actionButton.getTextColors()); actionButton.setClickable(!visible); } private void setNotifyProgressVisible(boolean visible){ notifyButton.setTextVisible(!visible); notifyProgress.setVisibility(visible ? View.VISIBLE : View.GONE); + if(visible) + notifyProgress.setIndeterminateTintList(notifyButton.getTextColors()); notifyButton.setClickable(!visible); } @@ -925,7 +990,8 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList @Override public void onSuccess(Account result){ editModeLoading=false; - if (getActivity() == null) return; + if(getActivity()==null) + return; enterEditMode(result); setActionProgressVisible(false); } @@ -933,7 +999,8 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList @Override public void onError(ErrorResponse error){ editModeLoading=false; - if (getActivity() == null) return; + if(getActivity()==null) + return; error.showToast(getActivity()); setActionProgressVisible(false); } @@ -941,46 +1008,60 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList .exec(accountID); } + private void updateMetadataHeight() { + ViewGroup.LayoutParams params = list.getLayoutParams(); + int desiredHeight = isInEditMode ? scrollView.getHeight() : ViewGroup.LayoutParams.WRAP_CONTENT; + if (params.height == desiredHeight) return; + params.height = desiredHeight; + list.requestLayout(); + } + private void enterEditMode(Account account){ if(isInEditMode) throw new IllegalStateException(); isInEditMode=true; - invalidateOptionsMenu(); - pager.setUserInputEnabled(false); - actionButton.setText(R.string.done); - ArrayList animators=new ArrayList<>(); - Drawable overlay=getResources().getDrawable(R.drawable.edit_avatar_overlay, getActivity().getTheme()).mutate(); - avatar.setForeground(overlay); - animators.add(ObjectAnimator.ofInt(overlay, "alpha", 0, 255)); - - nameWrap.setVisibility(View.GONE); - nameEdit.setVisibility(View.VISIBLE); - nameEdit.setText(account.displayName); - RelativeLayout.LayoutParams lp=(RelativeLayout.LayoutParams) username.getLayoutParams(); - lp.addRule(RelativeLayout.BELOW, R.id.name_edit); - username.getParent().requestLayout(); - animators.add(ObjectAnimator.ofFloat(nameEdit, View.ALPHA, 0f, 1f)); - - bioEdit.setVisibility(View.VISIBLE); - bioEdit.setText(account.source.note); - animators.add(ObjectAnimator.ofFloat(bioEdit, View.ALPHA, 0f, 1f)); - animators.add(ObjectAnimator.ofFloat(bio, View.ALPHA, 0f)); - profileCounters.setVisibility(View.GONE); - pager.setVisibility(View.GONE); - tabbar.setVisibility(View.GONE); - - AnimatorSet set=new AnimatorSet(); - set.playTogether(animators); - set.setDuration(300); - set.setInterpolator(CubicBezierInterpolator.DEFAULT); - set.start(); - -// aboutFragment.enterEditMode(account.source.fields); - - V.setVisibilityAnimated(fab, View.GONE); - metadataListData=account.source.fields; adapter.notifyDataSetChanged(); dragHelper.attachToRecyclerView(list); + editDirty=false; + invalidateOptionsMenu(); + actionButton.setText(R.string.save_changes); + pager.setVisibility(View.GONE); + tabbar.setVisibility(View.GONE); + Drawable overlay=getResources().getDrawable(R.drawable.edit_avatar_overlay).mutate(); + avatar.setForeground(overlay); + updateMetadataHeight(); + + Toolbar toolbar=getToolbar(); + Drawable close=getToolbarContext().getDrawable(R.drawable.ic_baseline_close_24).mutate(); + close.setTint(UiUtils.getThemeColor(getToolbarContext(), R.attr.colorM3OnSurfaceVariant)); + toolbar.setNavigationIcon(close); + toolbar.setNavigationContentDescription(R.string.discard); + + ViewGroup parent=contentView.findViewById(R.id.scrollable_content); + TransitionManager.beginDelayedTransition(parent, new TransitionSet() + .addTransition(new Fade(Fade.IN | Fade.OUT)) + .addTransition(new ChangeBounds()) + .setDuration(250) + .setInterpolator(CubicBezierInterpolator.DEFAULT) + ); + + name.setVisibility(View.GONE); + username.setVisibility(View.GONE); + bio.setVisibility(View.GONE); + countersLayout.setVisibility(View.GONE); + + nameEditWrap.setVisibility(View.VISIBLE); + nameEdit.setText(account.displayName); + + bioEditWrap.setVisibility(View.VISIBLE); + bioEdit.setText(account.source.note); + + refreshLayout.setEnabled(false); + editDirty=false; + V.setVisibilityAnimated(fab, View.GONE); + + fields = account.source.fields; + adapter.notifyDataSetChanged(); } private void exitEditMode(){ @@ -989,35 +1070,37 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList isInEditMode=false; invalidateOptionsMenu(); - ArrayList animators=new ArrayList<>(); actionButton.setText(R.string.edit_profile); - animators.add(ObjectAnimator.ofInt(avatar.getForeground(), "alpha", 0)); - animators.add(ObjectAnimator.ofFloat(nameEdit, View.ALPHA, 0f)); - animators.add(ObjectAnimator.ofFloat(bioEdit, View.ALPHA, 0f)); - animators.add(ObjectAnimator.ofFloat(bio, View.ALPHA, 1f)); - profileCounters.setVisibility(View.VISIBLE); + avatar.setForeground(null); + + Toolbar toolbar=getToolbar(); + if(canGoBack()){ + Drawable back=getToolbarContext().getDrawable(R.drawable.ic_fluent_arrow_left_24_regular).mutate(); + back.setTint(UiUtils.getThemeColor(getToolbarContext(), R.attr.colorM3OnSurfaceVariant)); + toolbar.setNavigationIcon(back); + toolbar.setNavigationContentDescription(0); + }else{ + toolbar.setNavigationIcon(null); + } + editSaveMenuItem=null; + + ViewGroup parent=contentView.findViewById(R.id.scrollable_content); + TransitionManager.beginDelayedTransition(parent, new TransitionSet() + .addTransition(new Fade(Fade.IN | Fade.OUT)) + .addTransition(new ChangeBounds()) + .setDuration(250) + .setInterpolator(CubicBezierInterpolator.DEFAULT) + ); + nameEditWrap.setVisibility(View.GONE); + bioEditWrap.setVisibility(View.GONE); + name.setVisibility(View.VISIBLE); + username.setVisibility(View.VISIBLE); + bio.setVisibility(View.VISIBLE); + countersLayout.setVisibility(View.VISIBLE); + refreshLayout.setEnabled(true); pager.setVisibility(View.VISIBLE); tabbar.setVisibility(View.VISIBLE); - V.setVisibilityAnimated(nameWrap, View.VISIBLE); - - AnimatorSet set=new AnimatorSet(); - set.playTogether(animators); - set.setDuration(200); - set.setInterpolator(CubicBezierInterpolator.DEFAULT); - set.addListener(new AnimatorListenerAdapter(){ - @Override - public void onAnimationEnd(Animator animation){ - pager.setUserInputEnabled(true); - nameEdit.setVisibility(View.GONE); - bioEdit.setVisibility(View.GONE); - RelativeLayout.LayoutParams lp=(RelativeLayout.LayoutParams) username.getLayoutParams(); - lp.addRule(RelativeLayout.BELOW, R.id.name_wrap); - username.getParent().requestLayout(); - avatar.setForeground(null); - scrollToTop(); - } - }); - set.start(); + updateMetadataHeight(); InputMethodManager imm=getActivity().getSystemService(InputMethodManager.class); imm.hideSoftInputFromWindow(content.getWindowToken(), 0); @@ -1029,10 +1112,12 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList if(!isInEditMode) throw new IllegalStateException(); setActionProgressVisible(true); - new UpdateAccountCredentials(nameEdit.getText().toString(), bioEdit.getText().toString(), editNewAvatar, editNewCover, metadataListData) + savingEdits=true; + new UpdateAccountCredentials(nameEdit.getText().toString(), bioEdit.getText().toString(), editNewAvatar, editNewCover, fields) .setCallback(new Callback<>(){ @Override public void onSuccess(Account result){ + savingEdits=false; account=result; AccountSessionManager.getInstance().updateAccountInfo(accountID, account); if (getActivity() == null) return; @@ -1042,6 +1127,7 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList @Override public void onError(ErrorResponse error){ + savingEdits=false; error.showToast(getActivity()); setActionProgressVisible(false); } @@ -1069,7 +1155,17 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList @Override public boolean onBackPressed(){ if(isInEditMode){ - exitEditMode(); + if(savingEdits) + return true; + if(editDirty){ + new M3AlertDialogBuilder(getActivity()) + .setTitle(R.string.discard_changes) + .setPositiveButton(R.string.discard, (dlg, btn)->exitEditMode()) + .setNegativeButton(R.string.cancel, null) + .show(); + }else{ + exitEditMode(); + } return true; } return false; @@ -1122,9 +1218,7 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList } private void startImagePicker(int requestCode){ - Intent intent=new Intent(Intent.ACTION_GET_CONTENT); - intent.setType("image/*"); - intent.addCategory(Intent.CATEGORY_OPENABLE); + Intent intent=UiUtils.getMediaPickerIntent(new String[]{"image/*"}, 1); startActivityForResult(intent, requestCode); } @@ -1133,10 +1227,12 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList if(resultCode==Activity.RESULT_OK){ if(requestCode==AVATAR_RESULT){ editNewAvatar=data.getData(); - ViewImageLoader.load(avatar, null, new UrlImageLoaderRequest(editNewAvatar, V.dp(100), V.dp(100))); + ViewImageLoader.loadWithoutAnimation(avatar, null, new UrlImageLoaderRequest(editNewAvatar, V.dp(100), V.dp(100))); + editDirty=true; }else if(requestCode==COVER_RESULT){ editNewCover=data.getData(); - ViewImageLoader.load(cover, null, new UrlImageLoaderRequest(editNewCover, V.dp(1000), V.dp(1000))); + ViewImageLoader.loadWithoutAnimation(cover, null, new UrlImageLoaderRequest(editNewCover, V.dp(1000), V.dp(1000))); + editDirty=true; } } } @@ -1161,6 +1257,10 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList Nav.go(getActivity(), cls, args); } + private boolean isActionButtonInView(){ + return actionButton.getVisibility()==View.VISIBLE && actionButtonWrap.getTop()+actionButtonWrap.getHeight()>scrollView.getScrollY(); + } + private class ProfilePagerAdapter extends RecyclerView.Adapter{ @NonNull @Override @@ -1180,6 +1280,7 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList holder.itemView.getViewTreeObserver().addOnPreDrawListener(new ViewTreeObserver.OnPreDrawListener(){ @Override public boolean onPreDraw(){ + getChildFragmentManager().executePendingTransactions(); if(fragment.isAdded()){ holder.itemView.getViewTreeObserver().removeOnPreDrawListener(this); applyChildWindowInsets(); @@ -1201,16 +1302,6 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList } } - // from ProfileAboutFragment - public void setFields(ArrayList fields){ - metadataListData=fields; - if (isInEditMode) { - isInEditMode=false; - dragHelper.attachToRecyclerView(null); - } - if (adapter != null) adapter.notifyDataSetChanged(); - } - @Override public void onProvideAssistContent(AssistContent assistContent) { callFragmentToProvideAssistContent(getFragmentForPage(pager.getCurrentItem()), assistContent); @@ -1226,8 +1317,19 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList return Uri.parse(account.url); } - private class MetadataAdapter extends UsableRecyclerView.Adapter implements ImageLoaderRecyclerAdapter { - public MetadataAdapter(){ + // from ProfileAboutFragment + public void setFields(List fields){ + this.fields=fields; + if(isInEditMode){ + isInEditMode=false; +// dragHelper.attachToRecyclerView(null); + } + if(adapter!=null) + adapter.notifyDataSetChanged(); + } + + private class AboutAdapter extends UsableRecyclerView.Adapter implements ImageLoaderRecyclerAdapter { + public AboutAdapter(){ super(imgLoader); } @@ -1244,8 +1346,8 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList @Override public void onBindViewHolder(BaseViewHolder holder, int position){ - if(position{ dragHelper.startDrag(this); return true; }); - title.addTextChangedListener(new SimpleTextWatcher(e->item.name=e.toString())); - value.addTextChangedListener(new SimpleTextWatcher(e->item.value=e.toString())); - findViewById(R.id.remove_row_btn).setOnClickListener(this::onRemoveRowClick); + title.addTextChangedListener(new SimpleTextWatcher(e->{ + item.name=e.toString(); + if(!ignoreTextChange) + editDirty=true; + })); + value.addTextChangedListener(new SimpleTextWatcher(e->{ + item.value=e.toString(); + if(!ignoreTextChange) + editDirty=true; + })); + findViewById(R.id.delete).setOnClickListener(this::onRemoveRowClick); } @Override public void onBind(AccountField item){ + super.onBind(item); + ignoreTextChange=true; title.setText(item.name); value.setText(item.value); + ignoreTextChange=false; } private void onRemoveRowClick(View v){ int pos=getAbsoluteAdapterPosition(); - metadataListData.remove(pos); + fields.remove(pos); adapter.notifyItemRemoved(pos); for(int i=0;itoPosition;i--) { - Collections.swap(metadataListData, i, i-1); + Collections.swap(fields, i, i-1); } } adapter.notifyItemMoved(fromPosition, toPosition); - ((BindableViewHolder)viewHolder).rebind(); - ((BindableViewHolder)target).rebind(); + ((BindableViewHolder)viewHolder).rebind(); + ((BindableViewHolder)target).rebind(); return true; } @@ -1422,7 +1530,6 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList if(actionState==ItemTouchHelper.ACTION_STATE_DRAG){ viewHolder.itemView.setTag(me.grishka.appkit.R.id.item_touch_helper_previous_elevation, viewHolder.itemView.getElevation()); // prevents the default behavior of changing elevation in onDraw() viewHolder.itemView.animate().translationZ(V.dp(1)).setDuration(200).setInterpolator(CubicBezierInterpolator.DEFAULT).start(); - draggedViewHolder=viewHolder; } } @@ -1430,7 +1537,6 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList public void clearView(@NonNull RecyclerView recyclerView, @NonNull RecyclerView.ViewHolder viewHolder){ super.clearView(recyclerView, viewHolder); viewHolder.itemView.animate().translationZ(0).setDuration(100).setInterpolator(CubicBezierInterpolator.DEFAULT).start(); - draggedViewHolder=null; } @Override diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/RecyclerFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/RecyclerFragment.java deleted file mode 100644 index 2dbc9650b..000000000 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/RecyclerFragment.java +++ /dev/null @@ -1,50 +0,0 @@ -package org.joinmastodon.android.fragments; - -import android.content.Context; -import android.os.Bundle; -import android.view.View; - -import androidx.swiperefreshlayout.widget.SwipeRefreshLayout; - -import org.joinmastodon.android.R; -import org.joinmastodon.android.ui.utils.UiUtils; - -import java.util.ArrayList; -import java.util.Arrays; -import java.util.List; - -import me.grishka.appkit.fragments.BaseRecyclerFragment; - - -public abstract class RecyclerFragment extends BaseRecyclerFragment { - public RecyclerFragment(int perPage) { - super(perPage); - } - - public RecyclerFragment(int layout, int perPage) { - super(layout, perPage); - } - - public void onViewCreated(View view, Bundle savedInstanceState) { - super.onViewCreated(view, savedInstanceState); - if (refreshLayout != null) setRefreshLayoutColors(refreshLayout); - } - - public static void setRefreshLayoutColors(SwipeRefreshLayout l) { - List colors = new ArrayList<>(Arrays.asList( - R.color.primary_600, - R.color.red_primary_600, - R.color.green_primary_600, - R.color.blue_primary_600, - R.color.purple_600 - )); - int primary = UiUtils.getThemeColorRes(l.getContext(), R.attr.colorPrimary600); - if (!colors.contains(primary)) colors.add(0, primary); - int offset = colors.indexOf(primary); - int[] sorted = new int[colors.size()]; - for (int i = 0; i < colors.size(); i++) { - sorted[i] = colors.get((i + offset) % colors.size()); - } - l.setColorSchemeResources(sorted); - } -} diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/ScheduledStatusListFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/ScheduledStatusListFragment.java index d882bb16f..ba02fc304 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/ScheduledStatusListFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/ScheduledStatusListFragment.java @@ -80,7 +80,7 @@ public class ScheduledStatusListFragment extends BaseStatusListFragment buildDisplayItems(ScheduledStatus s) { - return StatusDisplayItem.buildItems(this, s.toStatus(), accountID, s, knownAccounts, false, false, null, true, null); + return StatusDisplayItem.buildItems(this, s.toStatus(), accountID, s, knownAccounts, false, false, true, null); } @Override diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/SettingsFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/SettingsFragment.java deleted file mode 100644 index 20da1332a..000000000 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/SettingsFragment.java +++ /dev/null @@ -1,1356 +0,0 @@ -package org.joinmastodon.android.fragments; - -import android.animation.ObjectAnimator; -import android.annotation.SuppressLint; -import android.app.Activity; -import android.content.Intent; -import android.graphics.Bitmap; -import android.graphics.Canvas; -import android.graphics.Rect; -import android.net.Uri; -import android.os.Build; -import android.os.Bundle; -import android.util.LruCache; -import android.util.TypedValue; -import android.view.Gravity; -import android.view.LayoutInflater; -import android.view.Menu; -import android.view.MenuItem; -import android.view.View; -import android.view.ViewGroup; -import android.view.WindowInsets; -import android.view.WindowManager; -import android.view.animation.LinearInterpolator; -import android.widget.Button; -import android.widget.ImageButton; -import android.widget.ImageView; -import android.widget.LinearLayout; -import android.widget.PopupMenu; -import android.widget.ProgressBar; -import android.widget.RadioButton; -import android.widget.Switch; -import android.widget.TextView; -import android.widget.Toast; - -import com.squareup.otto.Subscribe; - -import org.joinmastodon.android.BuildConfig; -import org.joinmastodon.android.E; -import org.joinmastodon.android.GlobalUserPreferences; -import org.joinmastodon.android.GlobalUserPreferences.AutoRevealMode; -import org.joinmastodon.android.GlobalUserPreferences.ColorPreference; -import org.joinmastodon.android.GlobalUserPreferences.PrefixRepliesMode; -import org.joinmastodon.android.MainActivity; -import org.joinmastodon.android.MastodonApp; -import org.joinmastodon.android.R; -import org.joinmastodon.android.api.MastodonAPIController; -import org.joinmastodon.android.api.PushSubscriptionManager; -import org.joinmastodon.android.api.requests.oauth.RevokeOauthToken; -import org.joinmastodon.android.api.session.AccountActivationInfo; -import org.joinmastodon.android.api.session.AccountSession; -import org.joinmastodon.android.api.session.AccountSessionManager; -import org.joinmastodon.android.events.SelfUpdateStateChangedEvent; -import org.joinmastodon.android.fragments.onboarding.InstanceRulesFragment; -import org.joinmastodon.android.model.ContentType; -import org.joinmastodon.android.model.Instance; -import org.joinmastodon.android.fragments.onboarding.AccountActivationFragment; -import org.joinmastodon.android.model.PushNotification; -import org.joinmastodon.android.model.PushSubscription; -import org.joinmastodon.android.ui.M3AlertDialogBuilder; -import org.joinmastodon.android.ui.OutlineProviders; -import org.joinmastodon.android.ui.utils.UiUtils; -import org.joinmastodon.android.ui.views.TextInputFrameLayout; -import org.joinmastodon.android.updater.GithubSelfUpdater; -import org.joinmastodon.android.utils.ProvidesAssistContent; -import org.parceler.Parcels; - -import java.util.ArrayList; -import java.util.Optional; -import java.util.function.Consumer; - -import androidx.annotation.DrawableRes; -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.annotation.StringRes; -import androidx.recyclerview.widget.LinearLayoutManager; -import androidx.recyclerview.widget.RecyclerView; -import me.grishka.appkit.Nav; -import me.grishka.appkit.api.Callback; -import me.grishka.appkit.api.ErrorResponse; -import me.grishka.appkit.imageloader.ImageCache; -import me.grishka.appkit.utils.BindableViewHolder; -import me.grishka.appkit.utils.V; -import me.grishka.appkit.views.UsableRecyclerView; - -public class SettingsFragment extends MastodonToolbarFragment implements ProvidesAssistContent.ProvidesWebUri { - private UsableRecyclerView list; - private ArrayList items=new ArrayList<>(); - private ThemeItem themeItem; - private NotificationPolicyItem notificationPolicyItem; - private SwitchItem showNewPostsItem, glitchModeItem, compactReblogReplyLineItem, alwaysRevealSpoilersItem; - private ButtonItem defaultContentTypeButtonItem, autoRevealSpoilersItem; - private String accountID; - private boolean needUpdateNotificationSettings; - private boolean needAppRestart; - private PushSubscription pushSubscription; - - private ImageView themeTransitionWindowView; - private TextItem checkForUpdateItem, clearImageCacheItem; - private ImageCache imageCache; - private Menu contentTypeMenu; - - @SuppressLint("ClickableViewAccessibility") - @Override - public void onCreate(Bundle savedInstanceState){ - super.onCreate(savedInstanceState); - if(Build.VERSION.SDK_INT>=Build.VERSION_CODES.N) - setRetainInstance(true); - setTitle(R.string.settings); - imageCache = ImageCache.getInstance(getActivity()); - accountID=getArguments().getString("account"); - AccountSession session=AccountSessionManager.getInstance().getAccount(accountID); - Optional instance = session.getInstance(); - String instanceName = UiUtils.getInstanceName(accountID); - - if(GithubSelfUpdater.needSelfUpdating()){ - GithubSelfUpdater updater=GithubSelfUpdater.getInstance(); - GithubSelfUpdater.UpdateState state=updater.getState(); - if(state!=GithubSelfUpdater.UpdateState.NO_UPDATE && state!=GithubSelfUpdater.UpdateState.CHECKING){ - items.add(new UpdateItem()); - } - } - - items.add(new HeaderItem(R.string.settings_theme)); - items.add(themeItem=new ThemeItem()); - items.add(new SwitchItem(R.string.theme_true_black, R.drawable.ic_fluent_dark_theme_24_regular, GlobalUserPreferences.trueBlackTheme, this::onTrueBlackThemeChanged)); - items.add(new ButtonItem(R.string.sk_settings_color_palette, R.drawable.ic_fluent_color_24_regular, b->{ - PopupMenu popupMenu=new PopupMenu(getActivity(), b, Gravity.CENTER_HORIZONTAL); - popupMenu.inflate(R.menu.color_palettes); - popupMenu.getMenu().findItem(R.id.m3_color).setVisible(Build.VERSION.SDK_INT >= Build.VERSION_CODES.S); - popupMenu.setOnMenuItemClickListener(SettingsFragment.this::onColorPreferenceClick); - b.setOnTouchListener(popupMenu.getDragToOpenListener()); - b.setOnClickListener(v->popupMenu.show()); - b.setText(switch(GlobalUserPreferences.color){ - case MATERIAL3 -> R.string.sk_color_palette_material3; - case PINK -> R.string.sk_color_palette_pink; - case PURPLE -> R.string.sk_color_palette_purple; - case GREEN -> R.string.sk_color_palette_green; - case BLUE -> R.string.sk_color_palette_blue; - case BROWN -> R.string.sk_color_palette_brown; - case RED -> R.string.sk_color_palette_red; - case YELLOW -> R.string.sk_color_palette_yellow; - }); - })); - items.add(new ButtonItem(R.string.sk_settings_publish_button_text, R.drawable.ic_fluent_send_24_regular, b->{ - updatePublishText(b); - - b.setOnClickListener(l->{ - TextInputFrameLayout input = new TextInputFrameLayout( - getContext(), - getString(R.string.publish), - GlobalUserPreferences.publishButtonText.trim() - ); - new M3AlertDialogBuilder(getContext()).setTitle(R.string.sk_settings_publish_button_text_title).setView(input) - .setPositiveButton(R.string.save, (d, which) -> { - GlobalUserPreferences.publishButtonText = input.getEditText().getText().toString().trim(); - GlobalUserPreferences.save(); - updatePublishText(b); - }) - .setNeutralButton(R.string.clear, (d, which) -> { - GlobalUserPreferences.publishButtonText = ""; - GlobalUserPreferences.save(); - updatePublishText(b); - }) - .setNegativeButton(R.string.cancel, (d, which) -> {}) - .show(); - }); - })); - items.add(new SwitchItem(R.string.sk_settings_uniform_icon_for_notifications, R.drawable.ic_ntf_logo, GlobalUserPreferences.uniformNotificationIcon, i->{ - GlobalUserPreferences.uniformNotificationIcon=i.checked; - GlobalUserPreferences.save(); - })); - items.add(new SwitchItem(R.string.sk_disable_marquee, R.drawable.ic_fluent_text_more_24_regular, GlobalUserPreferences.disableMarquee, i->{ - GlobalUserPreferences.disableMarquee=i.checked; - GlobalUserPreferences.save(); - })); - items.add(new SwitchItem(R.string.sk_settings_reduce_motion, R.drawable.ic_fluent_star_emphasis_24_regular, GlobalUserPreferences.reduceMotion, i->{ - GlobalUserPreferences.reduceMotion=i.checked; - GlobalUserPreferences.save(); - })); - - items.add(new HeaderItem(R.string.settings_behavior)); - items.add(new SwitchItem(R.string.settings_gif, R.drawable.ic_fluent_gif_24_regular, GlobalUserPreferences.playGifs, i->{ - GlobalUserPreferences.playGifs=i.checked; - GlobalUserPreferences.save(); - })); - items.add(new SwitchItem(R.string.settings_custom_tabs, R.drawable.ic_fluent_link_24_regular, GlobalUserPreferences.useCustomTabs, i->{ - GlobalUserPreferences.useCustomTabs=i.checked; - GlobalUserPreferences.save(); - })); - items.add(new SwitchItem(R.string.sk_settings_show_interaction_counts, R.drawable.ic_fluent_number_row_24_regular, GlobalUserPreferences.showInteractionCounts, i->{ - GlobalUserPreferences.showInteractionCounts=i.checked; - GlobalUserPreferences.save(); - })); - items.add(alwaysRevealSpoilersItem = new SwitchItem(R.string.sk_settings_always_reveal_content_warnings, R.drawable.ic_fluent_chat_warning_24_regular, GlobalUserPreferences.alwaysExpandContentWarnings, i->{ - GlobalUserPreferences.alwaysExpandContentWarnings=i.checked; - GlobalUserPreferences.save(); - if (list.findViewHolderForAdapterPosition(items.indexOf(autoRevealSpoilersItem)) instanceof ButtonViewHolder bvh) bvh.rebind(); - })); - items.add(autoRevealSpoilersItem = new ButtonItem(R.string.sk_settings_auto_reveal_equal_spoilers, R.drawable.ic_fluent_eye_24_regular, b->{ - PopupMenu popupMenu=new PopupMenu(getActivity(), b, Gravity.CENTER_HORIZONTAL); - popupMenu.inflate(R.menu.settings_auto_reveal_spoiler); - popupMenu.setOnMenuItemClickListener(i -> onAutoRevealSpoilerClick(i, b)); - b.setOnTouchListener(popupMenu.getDragToOpenListener()); - b.setOnClickListener(v->popupMenu.show()); - onAutoRevealSpoilerChanged(b); - })); - items.add(new SwitchItem(R.string.sk_tabs_disable_swipe, R.drawable.ic_fluent_swipe_right_24_regular, GlobalUserPreferences.disableSwipe, i->{ - GlobalUserPreferences.disableSwipe=i.checked; - GlobalUserPreferences.save(); - needAppRestart=true; - })); - items.add(new SwitchItem(R.string.sk_enable_delete_notifications, R.drawable.ic_fluent_mail_inbox_dismiss_24_regular, GlobalUserPreferences.enableDeleteNotifications, i->{ - GlobalUserPreferences.enableDeleteNotifications=i.checked; - GlobalUserPreferences.save(); - needAppRestart=true; - })); - items.add(new SwitchItem(R.string.sk_settings_disable_alt_text_reminder, R.drawable.ic_fluent_image_alt_text_24_regular, GlobalUserPreferences.disableAltTextReminder, i->{ - GlobalUserPreferences.disableAltTextReminder=i.checked; - GlobalUserPreferences.save(); - })); - items.add(new SwitchItem(R.string.sk_settings_single_notification, R.drawable.ic_fluent_convert_range_24_regular, GlobalUserPreferences.keepOnlyLatestNotification, i->{ - GlobalUserPreferences.keepOnlyLatestNotification=i.checked; - GlobalUserPreferences.save(); - })); - items.add(new ButtonItem(R.string.sk_settings_prefix_reply_cw_with_re, R.drawable.ic_fluent_arrow_reply_24_regular, b->{ - PopupMenu popupMenu=new PopupMenu(getActivity(), b, Gravity.CENTER_HORIZONTAL); - popupMenu.inflate(R.menu.settings_prefix_reply_mode); - popupMenu.setOnMenuItemClickListener(i -> onPrefixRepliesClick(i, b)); - b.setOnTouchListener(popupMenu.getDragToOpenListener()); - b.setOnClickListener(v->popupMenu.show()); - b.setText(switch(GlobalUserPreferences.prefixReplies){ - case TO_OTHERS -> R.string.sk_settings_prefix_replies_to_others; - case ALWAYS -> R.string.sk_settings_prefix_replies_always; - default -> R.string.sk_settings_prefix_replies_never; - }); - GlobalUserPreferences.save(); - })); - items.add(new SwitchItem(R.string.sk_settings_confirm_before_reblog, R.drawable.ic_fluent_checkmark_circle_24_regular, GlobalUserPreferences.confirmBeforeReblog, i->{ - GlobalUserPreferences.confirmBeforeReblog=i.checked; - GlobalUserPreferences.save(); - })); - items.add(new SwitchItem(R.string.sk_settings_forward_report_default, R.drawable.ic_fluent_arrow_forward_24_regular, GlobalUserPreferences.forwardReportDefault, i->{ - GlobalUserPreferences.forwardReportDefault=i.checked; - GlobalUserPreferences.save(); - })); - items.add(new SwitchItem(R.string.sk_settings_allow_remote_loading, R.drawable.ic_fluent_communication_24_regular, GlobalUserPreferences.allowRemoteLoading, i->{ - GlobalUserPreferences.allowRemoteLoading=i.checked; - GlobalUserPreferences.save(); - })); - items.add(new SmallTextItem(R.string.sk_settings_allow_remote_loading_explanation)); - - items.add(new HeaderItem(R.string.sk_timelines)); - items.add(new SwitchItem(R.string.sk_settings_show_replies, R.drawable.ic_fluent_chat_multiple_24_regular, GlobalUserPreferences.showReplies, i->{ - GlobalUserPreferences.showReplies=i.checked; - GlobalUserPreferences.save(); - })); - if (isInstanceAkkoma()) { - items.add(new ButtonItem(R.string.sk_settings_reply_visibility, R.drawable.ic_fluent_chat_24_regular, b->{ - PopupMenu popupMenu=new PopupMenu(getActivity(), b, Gravity.CENTER_HORIZONTAL); - popupMenu.inflate(R.menu.reply_visibility); - popupMenu.setOnMenuItemClickListener(item -> this.onReplyVisibilityChanged(item, b)); - b.setOnTouchListener(popupMenu.getDragToOpenListener()); - b.setOnClickListener(v->popupMenu.show()); - b.setText(GlobalUserPreferences.replyVisibility == null ? - R.string.sk_settings_reply_visibility_all : - switch(GlobalUserPreferences.replyVisibility){ - case "following" -> R.string.sk_settings_reply_visibility_following; - case "self" -> R.string.sk_settings_reply_visibility_self; - default -> R.string.sk_settings_reply_visibility_all; - }); - })); - } - items.add(new SwitchItem(R.string.sk_settings_show_boosts, R.drawable.ic_fluent_arrow_repeat_all_24_regular, GlobalUserPreferences.showBoosts, i->{ - GlobalUserPreferences.showBoosts=i.checked; - GlobalUserPreferences.save(); - })); - items.add(new SwitchItem(R.string.sk_settings_load_new_posts, R.drawable.ic_fluent_arrow_sync_24_regular, GlobalUserPreferences.loadNewPosts, i->{ - GlobalUserPreferences.loadNewPosts=i.checked; - showNewPostsItem.enabled = i.checked; - if (!i.checked) { - GlobalUserPreferences.showNewPostsButton = false; - showNewPostsItem.checked = false; - } - if (list.findViewHolderForAdapterPosition(items.indexOf(showNewPostsItem)) instanceof SwitchViewHolder svh) svh.rebind(); - GlobalUserPreferences.save(); - })); - items.add(showNewPostsItem = new SwitchItem(R.string.sk_settings_see_new_posts_button, R.drawable.ic_fluent_arrow_up_24_regular, GlobalUserPreferences.showNewPostsButton, i->{ - GlobalUserPreferences.showNewPostsButton=i.checked; - GlobalUserPreferences.save(); - })); - items.add(new SwitchItem(R.string.sk_settings_show_alt_indicator, R.drawable.ic_fluent_scan_text_24_regular, GlobalUserPreferences.showAltIndicator, i->{ - GlobalUserPreferences.showAltIndicator=i.checked; - GlobalUserPreferences.save(); - })); - items.add(new SwitchItem(R.string.sk_settings_show_no_alt_indicator, R.drawable.ic_fluent_important_24_regular, GlobalUserPreferences.showNoAltIndicator, i->{ - GlobalUserPreferences.showNoAltIndicator=i.checked; - GlobalUserPreferences.save(); - })); - items.add(new SwitchItem(R.string.sk_settings_collapse_long_posts, R.drawable.ic_fluent_chevron_down_24_regular, GlobalUserPreferences.collapseLongPosts, i->{ - GlobalUserPreferences.collapseLongPosts=i.checked; - GlobalUserPreferences.save(); - })); - items.add(new SwitchItem(R.string.sk_settings_hide_interaction, R.drawable.ic_fluent_star_off_24_regular, GlobalUserPreferences.spectatorMode, i->{ - GlobalUserPreferences.spectatorMode=i.checked; - GlobalUserPreferences.save(); - needAppRestart=true; - })); - items.add(new SwitchItem(R.string.sk_settings_hide_fab, R.drawable.ic_fluent_edit_24_regular, GlobalUserPreferences.autoHideFab, i->{ - GlobalUserPreferences.autoHideFab=i.checked; - GlobalUserPreferences.save(); - needAppRestart=true; - })); - items.add(new SwitchItem(R.string.sk_reply_line_above_avatar, R.drawable.ic_fluent_arrow_reply_24_regular, GlobalUserPreferences.replyLineAboveHeader, i->{ - GlobalUserPreferences.replyLineAboveHeader=i.checked; - GlobalUserPreferences.compactReblogReplyLine=i.checked; - compactReblogReplyLineItem.enabled=i.checked; - compactReblogReplyLineItem.checked= GlobalUserPreferences.replyLineAboveHeader; - if (list.findViewHolderForAdapterPosition(items.indexOf(compactReblogReplyLineItem)) instanceof SwitchViewHolder svh) svh.rebind(); - GlobalUserPreferences.save(); - needAppRestart=true; - })); - items.add(compactReblogReplyLineItem=new SwitchItem(R.string.sk_compact_reblog_reply_line, R.drawable.ic_fluent_re_order_24_regular, GlobalUserPreferences.compactReblogReplyLine, i->{ - GlobalUserPreferences.compactReblogReplyLine=i.checked; - GlobalUserPreferences.save(); - needAppRestart=true; - })); - compactReblogReplyLineItem.enabled=GlobalUserPreferences.replyLineAboveHeader; - items.add(new SwitchItem(R.string.sk_settings_translate_only_opened, R.drawable.ic_fluent_translate_24_regular, GlobalUserPreferences.translateButtonOpenedOnly, i->{ - GlobalUserPreferences.translateButtonOpenedOnly=i.checked; - GlobalUserPreferences.save(); - needAppRestart=true; - })); - boolean translationAvailable = instance - .map(i -> i.v2 != null && i.v2.configuration.translation != null && i.v2.configuration.translation.enabled) - .orElse(false); - items.add(new SmallTextItem(getString(translationAvailable ? - R.string.sk_settings_translation_availability_note_available : - R.string.sk_settings_translation_availability_note_unavailable, instanceName))); - - items.add(new HeaderItem(R.string.settings_notifications)); - items.add(notificationPolicyItem=new NotificationPolicyItem()); - PushSubscription pushSubscription=getPushSubscription(); - boolean switchEnabled=pushSubscription.policy!=PushSubscription.Policy.NONE; - - items.add(new SwitchItem(R.string.notify_favorites, R.drawable.ic_fluent_star_24_regular, pushSubscription.alerts.favourite, i->onNotificationsChanged(PushNotification.Type.FAVORITE, i.checked), switchEnabled)); - items.add(new SwitchItem(R.string.notify_follow, R.drawable.ic_fluent_person_add_24_regular, pushSubscription.alerts.follow, i->onNotificationsChanged(PushNotification.Type.FOLLOW, i.checked), switchEnabled)); - items.add(new SwitchItem(R.string.notify_reblog, R.drawable.ic_fluent_arrow_repeat_all_24_regular, pushSubscription.alerts.reblog, i->onNotificationsChanged(PushNotification.Type.REBLOG, i.checked), switchEnabled)); - items.add(new SwitchItem(R.string.notify_mention, R.drawable.ic_fluent_mention_24_regular, pushSubscription.alerts.mention, i->onNotificationsChanged(PushNotification.Type.MENTION, i.checked), switchEnabled)); - items.add(new SwitchItem(R.string.sk_notify_posts, R.drawable.ic_fluent_chat_24_regular, pushSubscription.alerts.status, i->onNotificationsChanged(PushNotification.Type.STATUS, i.checked), switchEnabled)); - items.add(new SwitchItem(R.string.sk_notify_update, R.drawable.ic_fluent_history_24_regular, pushSubscription.alerts.update, i->onNotificationsChanged(PushNotification.Type.UPDATE, i.checked), switchEnabled)); - items.add(new SwitchItem(R.string.sk_notify_poll_results, R.drawable.ic_fluent_poll_24_regular, pushSubscription.alerts.poll, i->onNotificationsChanged(PushNotification.Type.POLL, i.checked), switchEnabled)); - - items.add(new HeaderItem(R.string.settings_account)); - items.add(new TextItem(R.string.sk_settings_profile, ()->UiUtils.launchWebBrowser(getActivity(), "https://"+session.domain+"/settings/profile"), R.drawable.ic_fluent_open_24_regular)); - items.add(new TextItem(R.string.sk_settings_posting, ()->UiUtils.launchWebBrowser(getActivity(), "https://"+session.domain+"/settings/preferences/other"), R.drawable.ic_fluent_open_24_regular)); - items.add(new TextItem(R.string.sk_settings_filters, ()->UiUtils.launchWebBrowser(getActivity(), "https://"+session.domain+"/filters"), R.drawable.ic_fluent_open_24_regular)); - items.add(new TextItem(R.string.sk_settings_auth, ()->UiUtils.launchWebBrowser(getActivity(), "https://"+session.domain+"/auth/edit"), R.drawable.ic_fluent_open_24_regular)); - - items.add(new HeaderItem(instanceName)); - items.add(new TextItem(R.string.sk_settings_rules, instance.map(i -> () -> { - Bundle args = new Bundle(); - args.putParcelable("instance", Parcels.wrap(i)); - Nav.go(getActivity(), InstanceRulesFragment.class, args); - }).orElse(null), R.drawable.ic_fluent_task_list_ltr_24_regular)); - items.add(new TextItem(R.string.sk_settings_about_instance , ()->UiUtils.launchWebBrowser(getActivity(), "https://"+session.domain+"/about"), R.drawable.ic_fluent_info_24_regular)); - items.add(new TextItem(R.string.settings_tos, ()->UiUtils.launchWebBrowser(getActivity(), "https://"+session.domain+"/terms"), R.drawable.ic_fluent_open_24_regular)); - items.add(new TextItem(R.string.settings_privacy_policy, ()->UiUtils.launchWebBrowser(getActivity(), "https://"+session.domain+"/terms"), R.drawable.ic_fluent_open_24_regular)); - items.add(new TextItem(R.string.log_out, this::confirmLogOut, R.drawable.ic_fluent_sign_out_24_regular)); - items.add(new SmallTextItem(instance - .map(i -> getString(R.string.sk_settings_server_version, i.version)) - .orElse(getString(R.string.sk_instance_info_unavailable)))); - - items.add(new HeaderItem(R.string.sk_instance_features)); - items.add(new SwitchItem(R.string.sk_settings_content_types, 0, GlobalUserPreferences.accountsWithContentTypesEnabled.contains(accountID), (i)->{ - if (i.checked) { - GlobalUserPreferences.accountsWithContentTypesEnabled.add(accountID); - if (GlobalUserPreferences.accountsDefaultContentTypes.get(accountID) == null) { - GlobalUserPreferences.accountsDefaultContentTypes.put(accountID, ContentType.PLAIN); - } - } else { - GlobalUserPreferences.accountsWithContentTypesEnabled.remove(accountID); - GlobalUserPreferences.accountsDefaultContentTypes.remove(accountID); - } - if (list.findViewHolderForAdapterPosition(items.indexOf(defaultContentTypeButtonItem)) - instanceof ButtonViewHolder bvh) bvh.rebind(); - GlobalUserPreferences.save(); - })); - items.add(new SmallTextItem(getString(R.string.sk_settings_content_types_explanation))); - items.add(defaultContentTypeButtonItem = new ButtonItem(R.string.sk_settings_default_content_type, 0, b->{ - PopupMenu popupMenu=new PopupMenu(getActivity(), b, Gravity.CENTER_HORIZONTAL); - popupMenu.inflate(R.menu.compose_content_type); - popupMenu.setOnMenuItemClickListener(item -> this.onContentTypeChanged(item, b)); - b.setOnTouchListener(popupMenu.getDragToOpenListener()); - b.setOnClickListener(v->popupMenu.show()); - ContentType contentType = GlobalUserPreferences.accountsDefaultContentTypes.get(accountID); - b.setText(getContentTypeString(contentType)); - contentTypeMenu = popupMenu.getMenu(); - contentTypeMenu.findItem(ContentType.getContentTypeRes(contentType)).setChecked(true); - instance.ifPresent(i -> ContentType.adaptMenuToInstance(contentTypeMenu, i)); - })); - items.add(new SmallTextItem(getString(R.string.sk_settings_default_content_type_explanation))); - items.add(new SwitchItem(R.string.sk_settings_support_local_only, 0, GlobalUserPreferences.accountsWithLocalOnlySupport.contains(accountID), i->{ - glitchModeItem.enabled = i.checked; - if (i.checked) { - GlobalUserPreferences.accountsWithLocalOnlySupport.add(accountID); - if (!isInstanceAkkoma()) { - GlobalUserPreferences.accountsInGlitchMode.add(accountID); - } - } else { - GlobalUserPreferences.accountsWithLocalOnlySupport.remove(accountID); - GlobalUserPreferences.accountsInGlitchMode.remove(accountID); - } - glitchModeItem.checked = GlobalUserPreferences.accountsInGlitchMode.contains(accountID); - if (list.findViewHolderForAdapterPosition(items.indexOf(glitchModeItem)) instanceof SwitchViewHolder svh) svh.rebind(); - GlobalUserPreferences.save(); - })); - items.add(new SmallTextItem(getString(R.string.sk_settings_local_only_explanation))); - items.add(glitchModeItem = new SwitchItem(R.string.sk_settings_glitch_instance, 0, GlobalUserPreferences.accountsInGlitchMode.contains(accountID), i->{ - if (i.checked) { - GlobalUserPreferences.accountsInGlitchMode.add(accountID); - } else { - GlobalUserPreferences.accountsInGlitchMode.remove(accountID); - } - GlobalUserPreferences.save(); - })); - glitchModeItem.enabled = GlobalUserPreferences.accountsWithLocalOnlySupport.contains(accountID); - items.add(new SmallTextItem(getString(R.string.sk_settings_glitch_mode_explanation))); - - items.add(new HeaderItem(R.string.sk_settings_about)); - items.add(new TextItem(R.string.sk_settings_contribute, ()->UiUtils.launchWebBrowser(getActivity(), "https://github.com/sk22/megalodon"), R.drawable.ic_fluent_open_24_regular)); - items.add(new TextItem(R.string.sk_settings_donate, ()->UiUtils.launchWebBrowser(getActivity(), "https://ko-fi.com/xsk22"), R.drawable.ic_fluent_heart_24_regular)); - LruCache cache = imageCache == null ? null : imageCache.getLruCache(); - clearImageCacheItem = new TextItem(R.string.settings_clear_cache, UiUtils.formatFileSize(getContext(), cache != null ? cache.size() : 0, true), this::clearImageCache, 0); - items.add(clearImageCacheItem); - items.add(new TextItem(R.string.sk_clear_recent_languages, ()->UiUtils.showConfirmationAlert(getActivity(), R.string.sk_clear_recent_languages, R.string.sk_confirm_clear_recent_languages, R.string.clear, ()->{ - GlobalUserPreferences.recentLanguages.remove(accountID); - GlobalUserPreferences.save(); - }))); - if (GithubSelfUpdater.needSelfUpdating()) { - items.add(new SwitchItem(R.string.sk_updater_enable_pre_releases, 0, GlobalUserPreferences.enablePreReleases, i->{ - GlobalUserPreferences.enablePreReleases=i.checked; - GlobalUserPreferences.save(); - })); - checkForUpdateItem = new TextItem(R.string.sk_check_for_update, GithubSelfUpdater.getInstance()::checkForUpdates); - items.add(checkForUpdateItem); - } - - if(BuildConfig.DEBUG){ - items.add(new RedHeaderItem("Debug options")); - items.add(new TextItem("Test e-mail confirmation flow", ()->{ - AccountSession sess=AccountSessionManager.getInstance().getAccount(accountID); - sess.activated=false; - sess.activationInfo=new AccountActivationInfo("test@email", System.currentTimeMillis()); - Bundle args=new Bundle(); - args.putString("account", accountID); - args.putBoolean("debug", true); - Nav.goClearingStack(getActivity(), AccountActivationFragment.class, args); - })); - } - - items.add(new FooterItem(getString(R.string.sk_settings_app_version, BuildConfig.VERSION_NAME, BuildConfig.VERSION_CODE))); - } - - private void updatePublishText(Button btn) { - if (GlobalUserPreferences.publishButtonText.isBlank()) btn.setText(R.string.publish); - else btn.setText(GlobalUserPreferences.publishButtonText); - } - - @Override - public void onAttach(Activity activity){ - super.onAttach(activity); - if(themeTransitionWindowView!=null){ - // Activity has finished recreating. Remove the overlay. - MastodonApp.context.getSystemService(WindowManager.class).removeView(themeTransitionWindowView); - themeTransitionWindowView=null; - } - } - - @Override - public View onCreateContentView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState){ - list=new UsableRecyclerView(getActivity()); - list.setLayoutManager(new LinearLayoutManager(getActivity())); - list.setAdapter(new SettingsAdapter()); - list.setBackgroundColor(UiUtils.getThemeColor(getActivity(), android.R.attr.colorBackground)); - list.setPadding(0, V.dp(16), 0, V.dp(12)); - list.setClipToPadding(false); - list.addItemDecoration(new RecyclerView.ItemDecoration(){ - @Override - public void getItemOffsets(@NonNull Rect outRect, @NonNull View view, @NonNull RecyclerView parent, @NonNull RecyclerView.State state){ - // Add 32dp gaps between sections - RecyclerView.ViewHolder holder=parent.getChildViewHolder(view); - if((holder instanceof HeaderViewHolder || holder instanceof FooterViewHolder) && holder.getAbsoluteAdapterPosition()>1) - outRect.top=V.dp(32); - } - }); - return list; - } - - @Override - public void onApplyWindowInsets(WindowInsets insets){ - if(Build.VERSION.SDK_INT>=29 && insets.getTappableElementInsets().bottom==0){ - list.setPadding(0, V.dp(16), 0, V.dp(12)+insets.getSystemWindowInsetBottom()); - insets=insets.inset(0, 0, 0, insets.getSystemWindowInsetBottom()); - } - super.onApplyWindowInsets(insets); - } - - @Override - public void onDestroy(){ - super.onDestroy(); - if(needUpdateNotificationSettings && PushSubscriptionManager.arePushNotificationsAvailable()){ - AccountSessionManager.getInstance().getAccount(accountID).getPushSubscriptionManager().updatePushSettings(pushSubscription); - } - if(needAppRestart) UiUtils.restartApp(); - } - - @Override - public void onViewCreated(View view, Bundle savedInstanceState){ - super.onViewCreated(view, savedInstanceState); - if(GithubSelfUpdater.needSelfUpdating()) - E.register(this); - } - - @Override - public void onDestroyView(){ - super.onDestroyView(); - if(GithubSelfUpdater.needSelfUpdating()) - E.unregister(this); - } - - private void onThemePreferenceClick(GlobalUserPreferences.ThemePreference theme){ - GlobalUserPreferences.theme=theme; - GlobalUserPreferences.save(); - restartActivityToApplyNewTheme(); - } - - private boolean onColorPreferenceClick(MenuItem item){ - ColorPreference pref = null; - int id = item.getItemId(); - - if (id == R.id.m3_color) pref = ColorPreference.MATERIAL3; - else if (id == R.id.pink_color) pref = ColorPreference.PINK; - else if (id == R.id.purple_color) pref = ColorPreference.PURPLE; - else if (id == R.id.green_color) pref = ColorPreference.GREEN; - else if (id == R.id.blue_color) pref = ColorPreference.BLUE; - else if (id == R.id.brown_color) pref = ColorPreference.BROWN; - else if (id == R.id.red_color) pref = ColorPreference.RED; - else if (id == R.id.yellow_color) pref = ColorPreference.YELLOW; - - if (pref == null) return false; - - GlobalUserPreferences.color=pref; - GlobalUserPreferences.save(); - restartActivityToApplyNewTheme(); - return true; - } - - private boolean onPrefixRepliesClick(MenuItem item, Button btn) { - int id = item.getItemId(); - PrefixRepliesMode mode = PrefixRepliesMode.NEVER; - if (id == R.id.prefix_replies_always) mode = PrefixRepliesMode.ALWAYS; - else if (id == R.id.prefix_replies_to_others) mode = PrefixRepliesMode.TO_OTHERS; - GlobalUserPreferences.prefixReplies = mode; - - btn.setText(switch(GlobalUserPreferences.prefixReplies){ - case TO_OTHERS -> R.string.sk_settings_prefix_replies_to_others; - case ALWAYS -> R.string.sk_settings_prefix_replies_always; - default -> R.string.sk_settings_prefix_replies_never; - }); - - return true; - } - - private boolean onAutoRevealSpoilerClick(MenuItem item, Button btn) { - int id = item.getItemId(); - - AutoRevealMode mode = AutoRevealMode.NEVER; - if (id == R.id.auto_reveal_threads) mode = AutoRevealMode.THREADS; - else if (id == R.id.auto_reveal_discussions) mode = AutoRevealMode.DISCUSSIONS; - - GlobalUserPreferences.alwaysExpandContentWarnings = false; - GlobalUserPreferences.autoRevealEqualSpoilers = mode; - GlobalUserPreferences.save(); - onAutoRevealSpoilerChanged(btn); - return true; - } - - private void onAutoRevealSpoilerChanged(Button b) { - if (GlobalUserPreferences.alwaysExpandContentWarnings) { - b.setText(R.string.sk_settings_auto_reveal_anyone); - } else { - b.setText(switch(GlobalUserPreferences.autoRevealEqualSpoilers){ - case THREADS -> R.string.sk_settings_auto_reveal_author; - case DISCUSSIONS -> R.string.sk_settings_auto_reveal_anyone; - default -> R.string.sk_settings_auto_reveal_nobody; - }); - if (alwaysRevealSpoilersItem.checked != GlobalUserPreferences.alwaysExpandContentWarnings) { - alwaysRevealSpoilersItem.checked = GlobalUserPreferences.alwaysExpandContentWarnings; - if (list.findViewHolderForAdapterPosition(items.indexOf(alwaysRevealSpoilersItem)) instanceof SwitchViewHolder svh) svh.rebind(); - } - } - } - - private void onTrueBlackThemeChanged(SwitchItem item){ - GlobalUserPreferences.trueBlackTheme=item.checked; - GlobalUserPreferences.save(); - - RecyclerView.ViewHolder themeHolder=list.findViewHolderForAdapterPosition(items.indexOf(themeItem)); - if(themeHolder!=null){ - ((ThemeViewHolder)themeHolder).bindSubitems(); - }else{ - list.getAdapter().notifyItemChanged(items.indexOf(themeItem)); - } - - if(UiUtils.isDarkTheme()){ - restartActivityToApplyNewTheme(); - } - } - - private @StringRes int getContentTypeString(@Nullable ContentType contentType) { - if (contentType == null) return R.string.sk_content_type_unspecified; - return switch (contentType) { - case PLAIN -> R.string.sk_content_type_plain; - case HTML -> R.string.sk_content_type_html; - case MARKDOWN -> R.string.sk_content_type_markdown; - case BBCODE -> R.string.sk_content_type_bbcode; - case MISSKEY_MARKDOWN -> R.string.sk_content_type_mfm; - }; - } - - private boolean onContentTypeChanged(MenuItem item, Button btn){ - int id = item.getItemId(); - - ContentType contentType = null; - if (id == R.id.content_type_plain) contentType = ContentType.PLAIN; - else if (id == R.id.content_type_html) contentType = ContentType.HTML; - else if (id == R.id.content_type_markdown) contentType = ContentType.MARKDOWN; - else if (id == R.id.content_type_bbcode) contentType = ContentType.BBCODE; - else if (id == R.id.content_type_misskey_markdown) contentType = ContentType.MISSKEY_MARKDOWN; - - GlobalUserPreferences.accountsDefaultContentTypes.put(accountID, contentType); - GlobalUserPreferences.save(); - btn.setText(getContentTypeString(contentType)); - item.setChecked(true); - return true; - } - - private boolean onReplyVisibilityChanged(MenuItem item, Button btn){ - String pref = null; - int id = item.getItemId(); - - if (id == R.id.reply_visibility_following) pref = "following"; - else if (id == R.id.reply_visibility_self) pref = "self"; - - GlobalUserPreferences.replyVisibility=pref; - GlobalUserPreferences.save(); - btn.setText(GlobalUserPreferences.replyVisibility == null ? - R.string.sk_settings_reply_visibility_all : - switch(GlobalUserPreferences.replyVisibility){ - case "following" -> R.string.sk_settings_reply_visibility_following; - case "self" -> R.string.sk_settings_reply_visibility_self; - default -> R.string.sk_settings_reply_visibility_all; - }); - return true; - } - - private void restartActivityToApplyNewTheme(){ - // Calling activity.recreate() causes a black screen for like half a second. - // So, let's take a screenshot and overlay it on top to create the illusion of a smoother transition. - // As a bonus, we can fade it out to make it even smoother. - if(Build.VERSION.SDK_INT>=Build.VERSION_CODES.N){ - View activityDecorView=getActivity().getWindow().getDecorView(); - Bitmap bitmap=Bitmap.createBitmap(activityDecorView.getWidth(), activityDecorView.getHeight(), Bitmap.Config.ARGB_8888); - activityDecorView.draw(new Canvas(bitmap)); - themeTransitionWindowView=new ImageView(MastodonApp.context); - themeTransitionWindowView.setImageBitmap(bitmap); - WindowManager.LayoutParams lp=new WindowManager.LayoutParams(WindowManager.LayoutParams.TYPE_APPLICATION); - lp.flags=WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE | WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE | - WindowManager.LayoutParams.FLAG_LAYOUT_INSET_DECOR | WindowManager.LayoutParams.FLAG_LAYOUT_IN_SCREEN | WindowManager.LayoutParams.FLAG_DRAWS_SYSTEM_BAR_BACKGROUNDS; - lp.systemUiVisibility=View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN | View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION | View.SYSTEM_UI_FLAG_LAYOUT_STABLE; - lp.systemUiVisibility|=(activityDecorView.getWindowSystemUiVisibility() & (View.SYSTEM_UI_FLAG_LIGHT_NAVIGATION_BAR | View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR)); - lp.width=lp.height=WindowManager.LayoutParams.MATCH_PARENT; - lp.token=getActivity().getWindow().getAttributes().token; - lp.windowAnimations=R.style.window_fade_out; - MastodonApp.context.getSystemService(WindowManager.class).addView(themeTransitionWindowView, lp); - } - getActivity().recreate(); - } - - private PushSubscription getPushSubscription(){ - if(pushSubscription!=null) - return pushSubscription; - AccountSession session=AccountSessionManager.getInstance().getAccount(accountID); - if(session.pushSubscription==null){ - pushSubscription=new PushSubscription(); - pushSubscription.alerts=PushSubscription.Alerts.ofAll(); - }else{ - pushSubscription=session.pushSubscription.clone(); - } - return pushSubscription; - } - - private void onNotificationsChanged(PushNotification.Type type, boolean enabled){ - PushSubscription subscription=getPushSubscription(); - switch(type){ - case FAVORITE -> subscription.alerts.favourite=enabled; - case FOLLOW -> subscription.alerts.follow=enabled; - case REBLOG -> subscription.alerts.reblog=enabled; - case MENTION -> subscription.alerts.mention=enabled; - case POLL -> subscription.alerts.poll=enabled; - case STATUS -> subscription.alerts.status=enabled; - case UPDATE -> subscription.alerts.update=enabled; - } - needUpdateNotificationSettings=true; - } - - private void onNotificationsPolicyChanged(PushSubscription.Policy policy){ - PushSubscription subscription=getPushSubscription(); - PushSubscription.Policy prevPolicy=subscription.policy; - if(prevPolicy==policy) - return; - subscription.policy=policy; - int index=items.indexOf(notificationPolicyItem); - RecyclerView.ViewHolder policyHolder=list.findViewHolderForAdapterPosition(index); - if(policyHolder!=null){ - ((NotificationPolicyViewHolder)policyHolder).rebind(); - }else{ - list.getAdapter().notifyItemChanged(index); - } - if((prevPolicy==PushSubscription.Policy.NONE)!=(policy==PushSubscription.Policy.NONE)){ - boolean newState=policy!=PushSubscription.Policy.NONE; - for(PushNotification.Type value : PushNotification.Type.values()){ - onNotificationsChanged(value, newState); - } - index++; - while(items.get(index) instanceof SwitchItem si){ - si.enabled=si.checked=newState; - RecyclerView.ViewHolder holder=list.findViewHolderForAdapterPosition(index); - if(holder!=null) - ((BindableViewHolder)holder).rebind(); - else - list.getAdapter().notifyItemChanged(index); - index++; - } - } - needUpdateNotificationSettings=true; - } - - private void confirmLogOut(){ - new M3AlertDialogBuilder(getActivity()) - .setTitle(R.string.log_out) - .setMessage(R.string.confirm_log_out) - .setPositiveButton(R.string.log_out, (dialog, which) -> logOut()) - .setNegativeButton(R.string.cancel, null) - .show(); - } - - private void logOut(){ - AccountSession session=AccountSessionManager.getInstance().getAccount(accountID); - new RevokeOauthToken(session.app.clientId, session.app.clientSecret, session.token.accessToken) - .setCallback(new Callback<>(){ - @Override - public void onSuccess(Object result){ - onLoggedOut(); - } - - @Override - public void onError(ErrorResponse error){ - onLoggedOut(); - } - }) - .wrapProgress(getActivity(), R.string.loading, false) - .exec(accountID); - } - - private void onLoggedOut(){ - if (getActivity() == null) return; - AccountSessionManager.getInstance().removeAccount(accountID); - getActivity().finish(); - Intent intent=new Intent(getActivity(), MainActivity.class); - startActivity(intent); - } - - private void clearImageCache(){ - MastodonAPIController.runInBackground(()->{ - Activity activity=getActivity(); - imageCache.clear(); - Toast.makeText(activity, R.string.media_cache_cleared, Toast.LENGTH_SHORT).show(); - }); - if (list.findViewHolderForAdapterPosition(items.indexOf(clearImageCacheItem)) instanceof TextViewHolder tvh) { - clearImageCacheItem.secondaryText = UiUtils.formatFileSize(getContext(), 0, true); - tvh.rebind(); - } - } - - @Subscribe - public void onSelfUpdateStateChanged(SelfUpdateStateChangedEvent ev){ - checkForUpdateItem.loading = ev.state == GithubSelfUpdater.UpdateState.CHECKING; - if (list.findViewHolderForAdapterPosition(items.indexOf(checkForUpdateItem)) instanceof TextViewHolder tvh) tvh.rebind(); - - UpdateItem updateItem = null; - if(items.get(0) instanceof UpdateItem item0) { - updateItem = item0; - } else if (ev.state != GithubSelfUpdater.UpdateState.CHECKING - && ev.state != GithubSelfUpdater.UpdateState.NO_UPDATE) { - updateItem = new UpdateItem(); - items.add(0, updateItem); - list.setAdapter(new SettingsAdapter()); - } - - if(updateItem != null && list.findViewHolderForAdapterPosition(0) instanceof UpdateViewHolder uvh){ - uvh.bind(updateItem); - } - - if (ev.state == GithubSelfUpdater.UpdateState.NO_UPDATE) { - Toast.makeText(getActivity(), R.string.sk_no_update_available, Toast.LENGTH_SHORT).show(); - } - } - - @Override - public Uri getWebUri(Uri.Builder base) { - return base.path(isInstanceAkkoma() ? "/about" : "/settings").build(); - } - - @Override - public String getAccountID() { - return accountID; - } - - private static abstract class Item{ - public abstract int getViewType(); - } - - private class HeaderItem extends Item{ - private String text; - - public HeaderItem(@StringRes int text){ - this.text=getString(text); - } - - public HeaderItem(String text){ - this.text=text; - } - - @Override - public int getViewType(){ - return 0; - } - } - - private class SwitchItem extends Item{ - private String text; - private int icon; - private boolean checked; - private Consumer onChanged; - private boolean enabled=true; - - public SwitchItem(@StringRes int text, @DrawableRes int icon, boolean checked, Consumer onChanged){ - this.text=getString(text); - this.icon=icon; - this.checked=checked; - this.onChanged=onChanged; - } - - public SwitchItem(@StringRes int text, @DrawableRes int icon, boolean checked, Consumer onChanged, boolean enabled){ - this.text=getString(text); - this.icon=icon; - this.checked=checked; - this.onChanged=onChanged; - this.enabled=enabled; - } - - @Override - public int getViewType(){ - return 1; - } - } - - public class ButtonItem extends Item{ - private int text; - private int icon; - private Consumer