Merge upstream redesign (#714)

* merge toolbar fragment

* Fix store screenshot generator

* Fix alert color

* Fix #609

* Fix crash

* bigger hitbox for chips

* support mastodon languages

* merge ui utils

* merge stuff

* fix icon

* ensure 48dp touch target

* init local prefs, add helper function for enum values

* update compose action layout

* merge compose-adj files

* update extended footer

* fix poll wrong option checked

closes sk22#641

* no border when disabled

closes sk22#640

* Fix #610

* Minor fixes

* Fix alert color

* Fix #609

* Fix crash

* Fix #610

* Minor fixes

* add resources

* more compatible mastodon language

* fix html parser

* mark as read on refresh

* update tab bar

* tweak m3 buttons

* update compose-adj files

* tweak and update styles

* m3 expand button

* flag icon should be 18dp, actually

* More minor fixes

closes #612

* More minor fixes

closes #612

* Bump version

* fix no create status event when redrafting

* add material 3 assets

* New translations strings.xml (Greek)

* New translations strings.xml (Greek)

* New translations strings.xml (Italian)

* New translations strings.xml (Greek)

* New translations strings.xml (Italian)

* New translations strings.xml (Thai)

* New translations strings.xml (Thai)

* New translations strings.xml (Italian)

* New translations strings.xml (Thai)

* use new buttons for profile fragment

* merge compose fragment

* merge all the styles! oh dear

* New translations full_description.txt (Indonesian)

* New translations full_description.txt (Chinese Simplified)

* New translations strings.xml (Chinese Simplified)

* New translations full_description.txt (Chinese Simplified)

* Fix #615

* Minor fixes

* Fix #611

* A bunch of crash fixes

* New translations strings.xml (Greek)

* Make the default server configurable

* Pass the system timezone to server when signing up

* New translations strings.xml (Chinese Simplified)

* New translations strings.xml (Japanese)

* Fix #615

* Minor fixes

* Fix #611

* A bunch of crash fixes

* Make the default server configurable

* Pass the system timezone to server when signing up

* oops. accidentally pasted the commit message in the code

* Remove unused code that caused a crash for some users ¯\_(ツ)_/¯

* New translations strings.xml (Japanese)

* New translations strings.xml (Japanese)

* Remove unused code that caused a crash for some users ¯\_(ツ)_/¯

* New translations strings.xml (Polish)

* New translations strings.xml (Polish)

* New translations strings.xml (Turkish)

* New translations strings.xml (Belarusian)

* prepare merging profile fragment

* merge profile fragment

* New translations strings.xml (Belarusian)

* New translations strings.xml (Greek)

* fix icon padding

* apply post header changes

* minor margin tweaks

* fix footer buttons

* fix header announcement buttons

* New translations strings.xml (Japanese)

* New translations strings.xml (Japanese)

* New translations strings.xml (Japanese)

* New translations strings.xml (Japanese)

* New translations strings.xml (Japanese)

* New translations strings.xml (Japanese)

* New translations full_description.txt (Japanese)

* New translations strings.xml (Icelandic)

* New translations strings.xml (Icelandic)

* New translations strings.xml (Icelandic)

* fix replying

* New translations strings.xml (Icelandic)

* fix translate button

* fix more button visibility

* fix counts label styling

* fix disabled boost button opacity

* fix tab layouts

* fix notification icon color crash

* New translations strings.xml (Greek)

* implement elevation listener in home tab

* fix elevation and listener in home tab

* add elevation scroll listener to notifications

* New translations strings.xml (Scottish Gaelic)

* Add editorconfig

So that PRs like #625 don't happen again

* Crash fix

* 🤔

* New translations strings.xml (Greek)

* New translations strings.xml (Japanese)

* New translations strings.xml (French)

* New translations strings.xml (French)

* New translations strings.xml (French)

* fix notification elevation and integrate divider

* 🤔

* Crash fix

* Add editorconfig

So that PRs like #625 don't happen again

* New translations strings.xml (Turkish)

* save interactions in cache

* New translations strings.xml (Turkish)

* merge new discover/search

* New translations strings.xml (Bengali)

* New translations strings.xml (Scottish Gaelic)

* New translations strings.xml (Bengali)

* merge new settings fragments

* fix no auth callback always being executed

* allow opening server info from profile

closes sk22#593

* fix hide boosts icon color

closes sk22#676

* New translations strings.xml (Turkish)

* New translations strings.xml (Turkish)

* New translations strings.xml (Turkish)

* New translations strings.xml (Chinese Simplified)

* New translations strings.xml (Turkish)

* New translations strings.xml (Chinese Simplified)

* New translations strings.xml (German)

* New translations strings.xml (German)

* New translations strings.xml (Turkish)

* update fedinuke list

from source; doesn't contain any modifications regarding a recent issue

* New translations strings.xml (Turkish)

* remove unused class

* fix crash

* darken m3 outline color a bit

* use m3 outline again

* fix misalignment

closes sk22#682

* New translations strings.xml (Turkish)

* New translations full_description.txt (Turkish)

* New translations short_description.txt (Turkish)

* fix crash

* fix metadata sorting

* show pronouns in header/account lists

* fix broken divider line

closes sk22#679

* trim pronouns

* improve pronoun display

* New translations strings.xml (French)

* New translations strings.xml (Japanese)

* fix broken federated timeline

closes sk22#685

* fix broken -1 fallback behavior

closes sk22#681

* don't display nothing if server about request fails

closes sk22#678

* New translations strings.xml (Ukrainian)

* migrate global prefs to local prefs

* do confirm unfollow by default

* New translations strings.xml (Ukrainian)

* New translations strings.xml (Ukrainian)

* New translations full_description.txt (Ukrainian)

* New translations strings.xml (Ukrainian)

* New translations strings.xml (Ukrainian)

* New translations strings.xml (Ukrainian)

* New translations strings.xml (Ukrainian)

* New translations strings.xml (Ukrainian)

* New translations strings.xml (Russian)

* New translations strings.xml (Vietnamese)

* New translations strings.xml (Ukrainian)

* New translations strings.xml (Vietnamese)

* New translations full_description.txt (Ukrainian)

* New translations strings.xml (Ukrainian)

* New translations strings.xml (Vietnamese)

* New translations strings.xml (Ukrainian)

* New translations strings.xml (Ukrainian)

* make sure list in prefs are always mutable and nut null

* New translations strings.xml (Ukrainian)

* New translations strings.xml (Ukrainian)

* New translations strings.xml (Russian)

* fix pronouns edge case

* add back fix for stretched images

closes sk22#636

* fix null pointer on missing default posting language

* fix default posting language not being applied

* bigger username hitbox

closes sk22#688

* fix rtl header username alignment

closes sk22#689

* New translations strings.xml (Ukrainian)

* New translations strings.xml (Ukrainian)

* hopefully fix crashes

closes sk22#692

* New translations strings.xml (Ukrainian)

* New translations full_description.txt (Ukrainian)

* fix pronoun crash

* New translations strings.xml (Persian)

* New translations strings.xml (Ukrainian)

* re-add true black mode

* asterisk can be a pronoun

* New translations strings.xml (Persian)

* true black mode fixes and clean-ups

* material 3 button background for switcher

* darker tab bar selected background

* better align follow/following button widths

* restore rainbow refresh colors

* fix search transition

* fix min width issue with switcher button

* fix no elevation when true black is enabled in light theme

* use statusForContent to determine spoilerRevealed

closes sk22#694

* New translations strings.xml (Persian)

* New translations strings.xml (Persian)

* New translations strings.xml (Persian)

* New translations strings.xml (Persian)

* New translations strings.xml (Persian)

* New translations strings.xml (Persian)

* fix profile tab bar in true black theme

* fix m3 default button style

closes sk22#697

* prettier role badges

closes sk22#663

* fix translate button spacing

closes sk22#655

* use m3 switches in dialogs

closes sk22#653

* implement color palette switcher

* fix color palettes being overwritten

* add display and notification settings

* clean up code

* per-account single notification setting

* add missing items to notification types

* add prefix replies setting

* add show replies/boosts and reply visibility

* add load/see new posts settings

* fix spectator mode missing spoiler padding

* add a bunch of display settings

* update fedinuke

* add content type settings

* add settings for local-onlu

* add missing settings items

* fix visibility button icon tint

* hopefully fix some crashes

* normalize padding above edit text

* apparently, some people don't like pills

closes sk22#706

* fix play button color

closes sk22#705
This commit is contained in:
sk22 2023-07-16 18:01:42 +02:00 committed by GitHub
parent 3cfea0e660
commit 7677ad39ca
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
746 changed files with 24879 additions and 13491 deletions

279
mastodon/.editorconfig Normal file
View File

@ -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

View File

@ -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'
}

View File

@ -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<MainActivity> 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");

View File

@ -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());
}
}

View File

@ -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<Filter> allFilters = List.of(hideMeFilter, warnMeFilter);
private static final LegacyFilter hideMeFilter = new LegacyFilter(), warnMeFilter = new LegacyFilter();
private static final List<LegacyFilter> allFilters = List.of(hideMeFilter, warnMeFilter);
private static final Status
hideInHomePublic = Status.ofFake(null, "hide me, please", Instant.now()),

View File

@ -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

View File

@ -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 @@
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW"/>
<category android:name="android.intent.category.BROWSABLE"/>
<category android:name="android.intent.category.DEFAULT"/>
<data android:scheme="https" android:host="mastodon.social" android:pathPrefix="/@"/>
</intent-filter>
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW"/>
<category android:name="android.intent.category.BROWSABLE"/>
<category android:name="android.intent.category.DEFAULT"/>
<data android:scheme="https" android:host="mastodon.online" android:pathPrefix="/@"/>
</intent-filter>
</activity>
<activity
android:name=".PanicResponderActivity"

View File

@ -1,6 +1,7 @@
13bells.com
1611.social
4aem.com
aethy.com
adachi.party
anime.website
annihilation.social
anon-kenkai.com
@ -9,14 +10,17 @@ bae.st
bajax.us
banepo.st
baraag.net
bassam.social
beefyboys.win
beepboop.ga
berserker.town
bikeshed.party
boks.moe
boymoder.biz
brainsoap.net
breastmilk.club
brighteon.social
bungle.online
cawfee.club
clew.lol
clubcyberia.co
@ -25,8 +29,8 @@ comfyboy.club
contrapointsfan.club
cum.camp
cum.salon
cybercriminal.eu
darknight-coffee.org
decayable.ink
dembased.xyz
desupost.soy
detroitriotcity.com
@ -40,7 +44,6 @@ fluf.club
foxfam.club
freak.university
freeatlantis.com
freecumextremist.com
freedomstrike.org
freesoftwareextremist.com
freespeech.group
@ -59,7 +62,6 @@ goyim.app
goyslop.cafe
haeder.net
handholding.io
hidamari.apartments
hitchhiker.social
hunk.city
iddqd.social
@ -75,14 +77,13 @@ leftychan.net
lewdieheaven.com
liberdon.com
ligma.pro
lizards.live
lolicon.rocks
lolison.top
lovingexpressions.net
lucasvl.nl
mahodou.moe
makemysarcophagus.com
maladaptive.art
marsey.moe
masochi.st
mastinator.com
merovingian.club
@ -105,7 +106,6 @@ nobodyhasthe.biz
nukem.biz
obo.sh
onionfarms.org
outpoa.st
pawlicker.com
pawoo.net
pedo.school
@ -121,6 +121,7 @@ poast.tv
poster.place
prospeech.space
quodverum.com
r18.social
rakket.app
rapemeat.solutions
rdrama.cc
@ -132,7 +133,6 @@ schwartzwelt.xyz
seal.cafe
shigusegubu.club
shitpost.cloud
shitposter.club
shota.house
silliness.observer
skinheads.eu
@ -149,7 +149,6 @@ sonichu.com
spinster.xyz
springbo.cc
starnix.network
stereophonic.space
strelizia.net
syspxl.xyz
tastingtraffic.net

View File

@ -0,0 +1,51 @@
<!DOCTYPE html>
<html>
<head>
<style>
*{
box-sizing: border-box;
overflow-wrap: break-word;
}
body{
background: {{colorSurface}};
padding: 16px 16px 0 16px;
margin: 0;
color: {{colorOnSurface}};
font-family: Roboto, sans-serif;
font-size: 14px;
line-height: 20px;
-webkit-tap-highlight-color: {{colorPrimaryTransparent}};
}
a{
text-decoration: none;
color: {{colorPrimary}};
}
p, h1, h2, h3, h4, h5, h6, ul, ol{
margin-bottom: 8px;
margin-top: 0;
}
h1, h2{
font-size: 16px;
line-height: 24px;
font-weight: 500;
}
h3, h4, h5, h6{
font-size: 14px;
line-height: 20px;
font-weight: 500;
}
b, strong{
font-weight: 500;
}
ul, ol{
padding-inline-start: 16px;
}
ul>li, ol>li{
padding-inline-start: 4px;
}
</style>
</head>
<body>
{{content}}
</body>
</html>

View File

@ -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<Callback> 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
}
}

View File

@ -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<String, List<String>> recentLanguages;
public static Map<String, List<TimelineDefinition>> pinnedTimelines;
public static Set<String> accountsWithLocalOnlySupport;
public static Set<String> accountsInGlitchMode;
public static Set<String> accountsWithContentTypesEnabled;
public static Map<String, ContentType> accountsDefaultContentTypes;
private final static Type recentLanguagesType = new TypeToken<Map<String, List<String>>>() {}.getType();
private final static Type pinnedTimelinesType = new TypeToken<Map<String, List<TimelineDefinition>>>() {}.getType();
private final static Type accountsDefaultContentTypesType = new TypeToken<Map<String, ContentType>>() {}.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> 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> 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 extends Enum<T>> T enumValue(Class<T> enumType, String name) {
try { return Enum.valueOf(enumType, name); }
catch (NullPointerException npe) { return null; }
}
public static void load(){
SharedPreferences prefs=getPrefs();
playGifs=prefs.getBoolean("playGifs", true);
useCustomTabs=prefs.getBoolean("useCustomTabs", true);
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<Map<String, ContentType>>() {}.getType();
Type pinnedTimelinesType = new TypeToken<Map<String, ArrayList<TimelineDefinition>>>() {}.getType();
Type recentLanguagesType = new TypeToken<Map<String, ArrayList<String>>>() {}.getType();
// migrate global preferences
SharedPreferences prefs=getPrefs();
altTextReminders=!prefs.getBoolean("disableAltTextReminder", false);
confirmBoost=prefs.getBoolean("confirmBeforeReblog", false);
toolbarMarquee=!prefs.getBoolean("disableMarquee", false);
save();
// migrate local preferences
AccountSessionManager asm=AccountSessionManager.getInstance();
// reset: Set<String> accountsWithContentTypesEnabled=prefs.getStringSet("accountsWithContentTypesEnabled", new HashSet<>());
Map<String, ContentType> accountsDefaultContentTypes=fromJson(prefs.getString("accountsDefaultContentTypes", null), accountsDefaultContentTypesType, new HashMap<>());
Map<String, ArrayList<TimelineDefinition>> pinnedTimelines=fromJson(prefs.getString("pinnedTimelines", null), pinnedTimelinesType, new HashMap<>());
Set<String> accountsWithLocalOnlySupport=prefs.getStringSet("accountsWithLocalOnlySupport", new HashSet<>());
Set<String> accountsInGlitchMode=prefs.getStringSet("accountsInGlitchMode", new HashSet<>());
Map<String, ArrayList<String>> recentLanguages=fromJson(prefs.getString("recentLanguages", null), recentLanguagesType, new HashMap<>());
for(AccountSession session : asm.getLoggedInAccounts()){
String accountID=session.getID();
AccountLocalPreferences localPrefs=session.getLocalPreferences();
localPrefs.revealCWs=prefs.getBoolean("alwaysExpandContentWarnings", false);
localPrefs.recentLanguages=recentLanguages.get(accountID);
// reset: localPrefs.contentTypesEnabled=accountsWithContentTypesEnabled.contains(accountID);
localPrefs.defaultContentType=accountsDefaultContentTypes.getOrDefault(accountID, ContentType.PLAIN);
localPrefs.showInteractionCounts=prefs.getBoolean("showInteractionCounts", false);
localPrefs.timelines=pinnedTimelines.getOrDefault(accountID, TimelineDefinition.getDefaultTimelines(accountID));
localPrefs.localOnlySupported=accountsWithLocalOnlySupport.contains(accountID);
localPrefs.glitchInstance=accountsInGlitchMode.contains(accountID);
localPrefs.publishButtonText=prefs.getString("publishButtonText", null);
localPrefs.keepOnlyLatestNotification=prefs.getBoolean("keepOnlyLatestNotification", false);
localPrefs.showReplies=prefs.getBoolean("showReplies", true);
localPrefs.showBoosts=prefs.getBoolean("showBoosts", true);
if(session.getInstance().map(Instance::isAkkoma).orElse(false)){
localPrefs.timelineReplyVisibility=prefs.getString("replyVisibility", null);
}
localPrefs.save();
}
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{

View File

@ -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();

View File

@ -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);
}
}
}

View File

@ -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<String, Integer> 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){

View File

@ -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<Callback<PaginatedResponse<List<Notification>>>> pendingNotificationsCallbacks=new ArrayList<>();
private static final int POST_FLAG_GAP_AFTER=1;
@ -58,7 +58,6 @@ public class CacheController{
cancelDelayedClose();
databaseThread.postRunnable(()->{
try{
List<Filter> 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`<?", maxID==null ? null : new String[]{maxID}, null, null, "`time` DESC", count+"")){
@ -66,18 +65,16 @@ public class CacheController{
ArrayList<Status> 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<Status> 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<Status> filtered=new ArrayList<>(result);
AccountSessionManager.get(accountID).filterStatuses(filtered, FilterContext.HOME);
callback.onSuccess(new CacheablePaginatedResponse<>(filtered, result.isEmpty() ? null : result.get(result.size()-1).id, false));
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<CacheablePaginatedResponse<List<Notification>>> callback){
public void updateStatus(Status status) {
runOnDbThread((db)->{
ContentValues statusUpdate=new ContentValues(1);
statusUpdate.put("json", MastodonAPIController.gson.toJson(status));
db.update("home_timeline", statusUpdate, "id = ?", new String[] { status.id });
});
}
public void updateNotification(Notification notification) {
runOnDbThread((db)->{
ContentValues notificationUpdate=new ContentValues(1);
notificationUpdate.put("json", MastodonAPIController.gson.toJson(notification));
String[] notificationArgs = new String[] { notification.id };
db.update("notifications_all", notificationUpdate, "id = ?", notificationArgs);
db.update("notifications_mentions", notificationUpdate, "id = ?", notificationArgs);
db.update("notifications_posts", notificationUpdate, "id = ?", notificationArgs);
ContentValues statusUpdate=new ContentValues(1);
statusUpdate.put("json", MastodonAPIController.gson.toJson(notification.status));
db.update("home_timeline", statusUpdate, "id = ?", new String[] { notification.status.id });
});
}
public void getNotifications(String maxID, int count, boolean onlyMentions, boolean onlyPosts, boolean forceReload, Callback<PaginatedResponse<List<Notification>>> callback){
cancelDelayedClose();
databaseThread.postRunnable(()->{
try{
AccountSession accountSession=AccountSessionManager.getInstance().getAccount(accountID);
List<Filter> 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<Notification> 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<Notification> 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<Notification> filtered=new ArrayList<>(result);
AccountSessionManager.get(accountID).filterStatusContainingObjects(filtered, n->n.status, FilterContext.NOTIFICATIONS);
PaginatedResponse<List<Notification>> res=new PaginatedResponse<>(filtered, result.isEmpty() ? null : result.get(result.size()-1).id);
callback.onSuccess(res);
putNotifications(result, onlyMentions, onlyPosts, maxID==null);
if(!onlyMentions){
loadingNotifications=false;
synchronized(pendingNotificationsCallbacks){
for(Callback<PaginatedResponse<List<Notification>>> cb:pendingNotificationsCallbacks){
cb.onSuccess(res);
}
pendingNotificationsCallbacks.clear();
}
}
}
@Override
public void onError(ErrorResponse error){
callback.onError(error);
if(!onlyMentions){
loadingNotifications=false;
synchronized(pendingNotificationsCallbacks){
for(Callback<PaginatedResponse<List<Notification>>> 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){

View File

@ -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")) {

View File

@ -49,6 +49,7 @@ public abstract class MastodonAPIRequest<T> extends APIRequest<T>{
Token token;
boolean canceled, isRemote;
Map<String, String> headers;
long timeout;
private ProgressDialog progressDialog;
protected boolean removeUnsupportedItems;
@Nullable Context context;
@ -117,7 +118,7 @@ public abstract class MastodonAPIRequest<T> extends APIRequest<T>{
.findAny())
.map(AccountSession::getID)
.map(this::exec)
.orElse(this.execNoAuth(domain));
.orElseGet(() -> this.execNoAuth(domain));
}
public MastodonAPIRequest<T> wrapProgress(Activity activity, @StringRes int message, boolean cancelable){
@ -152,6 +153,10 @@ public abstract class MastodonAPIRequest<T> extends APIRequest<T>{
headers.put(key, value);
}
protected void setTimeout(long timeout){
this.timeout=timeout;
}
protected String getPathPrefix(){
return "/api/v1";
}

View File

@ -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;

View File

@ -0,0 +1,9 @@
package org.joinmastodon.android.api;
import com.google.gson.reflect.TypeToken;
public abstract class ResultlessMastodonAPIRequest extends MastodonAPIRequest<Void>{
public ResultlessMastodonAPIRequest(HttpMethod method, String path){
super(method, path, (Class<Void>)null);
}
}

View File

@ -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<List<Hashtag>>{
public GetAccountFeaturedHashtags(String id){
super(HttpMethod.GET, "/accounts/"+id+"/featured_tags", new TypeToken<>(){});
}
}

View File

@ -21,22 +21,22 @@ public class GetAccountStatuses extends MastodonAPIRequest<List<Status>>{
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
}
}

View File

@ -4,21 +4,22 @@ import org.joinmastodon.android.api.MastodonAPIRequest;
import org.joinmastodon.android.model.Token;
public class RegisterAccount extends MastodonAPIRequest<Token>{
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;
}
}
}

View File

@ -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<Account>{
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;
}
}
}

View File

@ -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<List<CatalogDefaultInstance>>{
public GetCatalogDefaultInstances(){
super(HttpMethod.GET, null, new TypeToken<>(){});
setTimeout(500);
}
@Override
public Uri getURL(){
return Uri.parse("https://api.joinmastodon.org/default-servers");
}
}

View File

@ -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<Filter>{
public CreateFilter(String title, EnumSet<FilterContext> context, FilterAction action, int expiresIn, List<FilterKeyword> 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";
}
}

View File

@ -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";
}
}

View File

@ -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<FilterContext> context;
public FilterAction filterAction;
public Integer expiresIn;
public List<KeywordAttribute> keywordsAttributes;
public FilterRequest(String title, EnumSet<FilterContext> context, FilterAction filterAction, Integer expiresIn, List<KeywordAttribute> keywordsAttributes){
this.title=title;
this.context=context;
this.filterAction=filterAction;
this.expiresIn=expiresIn;
this.keywordsAttributes=keywordsAttributes;
}
}

View File

@ -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<List<Filter>>{
public GetWordFilters(){
public class GetFilters extends MastodonAPIRequest<List<Filter>>{
public GetFilters(){
super(HttpMethod.GET, "/filters", new TypeToken<>(){});
}
@Override
protected String getPathPrefix(){
return "/api/v2";
}
}

View File

@ -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<List<LegacyFilter>>{
public GetLegacyFilters(){
super(HttpMethod.GET, "/filters", new TypeToken<>(){});
}
}

View File

@ -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;
}
}

View File

@ -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<Filter>{
public UpdateFilter(String id, String title, EnumSet<FilterContext> context, FilterAction action, int expiresIn, List<FilterKeyword> words, List<String> deletedWords){
super(HttpMethod.PUT, "/filters/"+id, Filter.class);
List<KeywordAttribute> 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";
}
}

View File

@ -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<GetInstanceExtendedDescription.Response>{
public GetInstanceExtendedDescription(){
super(HttpMethod.GET, "/instance/extended_description", Response.class);
}
public static class Response{
public Instant updatedAt;
public String content;
}
}

View File

@ -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<Markers> {
public GetMarkers(EnumSet<Marker.Type> timelines) {
super(HttpMethod.GET, "/markers", Markers.class);
for (String type : ApiUtils.enumSetToStrings(timelines, Marker.Type.class)){
addQueryParameter("timeline[]", type);
}
public class GetMarkers extends MastodonAPIRequest<TimelineMarkers>{
public GetMarkers(){
super(HttpMethod.GET, "/markers", TimelineMarkers.class);
addQueryParameter("timeline[]", "home");
addQueryParameter("timeline[]", "notifications");
}
}

View File

@ -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<SaveMarkers.Response>{
public class SaveMarkers extends MastodonAPIRequest<TimelineMarkers>{
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<SaveMarkers.Response>{
builder.add("notifications", new JsonObjectBuilder().add("last_read_id", lastSeenNotificationID));
setRequestBody(builder.build());
}
public static class Response{
public Marker home, notifications;
}
}

View File

@ -13,6 +13,11 @@ public class GetSearchResults extends MastodonAPIRequest<SearchResults>{
addQueryParameter("resolve", "true");
}
public GetSearchResults limit(int limit){
addQueryParameter("limit", String.valueOf(limit));
return this;
}
@Override
protected String getPathPrefix(){
return "/api/v2";

View File

@ -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<List<Status>> {
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);
}
}

View File

@ -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<List<Status>>{
public GetHashtagTimeline(String hashtag, String maxID, String minID, int limit, List<String> containsAny, List<String> containsAll, List<String> containsNone, boolean localOnly){
public GetHashtagTimeline(String hashtag, String maxID, String minID, int limit, List<String> containsAny, List<String> containsAll, List<String> containsNone, boolean localOnly, String replyVisibility){
super(HttpMethod.GET, "/timelines/tag/"+hashtag, new TypeToken<>(){});
if (localOnly)
addQueryParameter("local", "true");
@ -30,17 +27,7 @@ public class GetHashtagTimeline extends MastodonAPIRequest<List<Status>>{
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);
}
}

View File

@ -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<List<Status>>{
public GetHomeTimeline(String maxID, String minID, int limit, String sinceID){
public GetHomeTimeline(String maxID, String minID, int limit, String sinceID, String replyVisibility){
super(HttpMethod.GET, "/timelines/home", new TypeToken<>(){});
if(maxID!=null)
addQueryParameter("max_id", maxID);
@ -19,7 +18,7 @@ public class GetHomeTimeline extends MastodonAPIRequest<List<Status>>{
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);
}
}

View File

@ -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<List<Status>> {
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<List<Status>> {
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);
}
}

View File

@ -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<List<Status>>{
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<List<Status>>{
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);
}
}

View File

@ -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<String> recentLanguages;
public boolean bottomEncoding;
public ContentType defaultContentType;
public boolean contentTypesEnabled;
public ArrayList<TimelineDefinition> timelines;
public boolean localOnlySupported;
public boolean glitchInstance;
public String publishButtonText;
public String timelineReplyVisibility; // akkoma-only
public boolean keepOnlyLatestNotification;
private final static Type recentLanguagesType = new TypeToken<ArrayList<String>>() {}.getType();
private final static Type timelinesType = new TypeToken<ArrayList<TimelineDefinition>>() {}.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();
}
}

View File

@ -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<Filter> wordFilters=new ArrayList<>();
public List<LegacyFilter> 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<Preferences> callback){
new GetPreferences()
.setCallback(new Callback<>(){
@Override
public void onSuccess(Preferences result){
preferences=result;
preferencesFromAccountSource(self);
if(callback!=null)
callback.accept(result);
AccountSessionManager.getInstance().writeAccountsFile();
}
@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<String> 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<Status> statuses, FilterContext context){
filterStatuses(statuses, context, null);
}
public void filterStatuses(List<Status> statuses, FilterContext context, Account profile){
filterStatusContainingObjects(statuses, Function.identity(), context, profile);
}
public <T> void filterStatusContainingObjects(List<T> objects, Function<T, Status> extractor, FilterContext context){
filterStatusContainingObjects(objects, extractor, context, null);
}
public <T> void filterStatusContainingObjects(List<T> objects, Function<T, Status> extractor, FilterContext context, Account profile){
Predicate<Status> 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<Instance> getInstance() {
return Optional.ofNullable(AccountSessionManager.getInstance().getInstanceInfo(domain));
}

View File

@ -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<AccountSession> 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<String> 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<Filter> 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<LegacyFilter> 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){

View File

@ -1,4 +0,0 @@
package org.joinmastodon.android.events;
public class AllNotificationsSeenEvent {
}

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

@ -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;

View File

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

View File

@ -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<Status> 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

View File

@ -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<T extends DisplayItemsParent> extends RecyclerFragment<T> implements PhotoViewerHost, ScrollableToTop, IsOnTop, HasFab, ProvidesAssistContent.ProvidesWebUri {
public abstract class BaseStatusListFragment<T extends DisplayItemsParent> extends MastodonRecyclerFragment<T> implements PhotoViewerHost, ScrollableToTop, IsOnTop, HasFab, ProvidesAssistContent.ProvidesWebUri {
protected ArrayList<StatusDisplayItem> displayItems=new ArrayList<>();
protected DisplayItemsAdapter adapter;
protected String accountID;
@ -96,7 +98,7 @@ public abstract class BaseStatusListFragment<T extends DisplayItemsParent> 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<T extends DisplayItemsParent> 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<T extends DisplayItemsParent> exten
}
protected int getMainAdapterOffset(){
if(list.getAdapter() instanceof MergeRecyclerAdapter mergeAdapter){
return mergeAdapter.getPositionForAdapter(adapter);
}
return 0;
}
@ -438,6 +447,10 @@ public abstract class BaseStatusListFragment<T extends DisplayItemsParent> 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<T extends DisplayItemsParent> 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<T extends DisplayItemsParent> exten
if (header != null) header.rebind();
}
protected void updateImagesSpoilerState(Status status, String itemID){
ArrayList<Integer> 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<T extends DisplayItemsParent> 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<StatusDisplayItem> holder){}
@Override
protected void onDataLoaded(List<T> d, boolean more) {
if (getContext()==null) return;
super.onDataLoaded(d, more);
// more available, but the page isn't even full yet? seems wrong, let's load some more
if (more && 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<BindableViewHolder<StatusDisplayItem>> implements ImageLoaderRecyclerAdapter{
@ -770,7 +793,9 @@ public abstract class BaseStatusListFragment<T extends DisplayItemsParent> exten
@NonNull
@Override
public BindableViewHolder<StatusDisplayItem> onCreateViewHolder(@NonNull ViewGroup parent, int viewType){
return (BindableViewHolder<StatusDisplayItem>) StatusDisplayItem.createViewHolder(StatusDisplayItem.Type.values()[viewType & (~0x80000000)], getActivity(), parent);
BindableViewHolder<StatusDisplayItem> holder=(BindableViewHolder<StatusDisplayItem>) StatusDisplayItem.createViewHolder(StatusDisplayItem.Type.values()[viewType & (~0x80000000)], getActivity(), parent, BaseStatusListFragment.this);
onModifyItemViewHolder(holder);
return holder;
}
@Override
@ -801,15 +826,12 @@ public abstract class BaseStatusListFragment<T extends DisplayItemsParent> 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<T extends DisplayItemsParent> 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<parent.getChildCount();i++){
View child=parent.getChildAt(i);
RecyclerView.ViewHolder holder=parent.getChildViewHolder(child);
if(holder instanceof MediaGridStatusDisplayItem.Holder imgHolder){
if(!imgHolder.getItem().status.spoilerRevealed && TextUtils.isEmpty(imgHolder.getItem().status.spoilerText)){
hiddenMediaPaint.setColor(0x80000000);
c.drawRect(child.getX(), child.getY(), child.getX()+child.getWidth(), child.getY()+child.getHeight(), hiddenMediaPaint);
}
}
private boolean needDrawDivider(RecyclerView.ViewHolder holder, RecyclerView.ViewHolder siblingHolder){
if(needDividerForExtraItem(holder.itemView, siblingHolder.itemView, holder, siblingHolder))
return true;
if(holder instanceof StatusDisplayItem.Holder<?> 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<parent.getChildCount();i++){
View child=parent.getChildAt(i);
RecyclerView.ViewHolder holder=parent.getChildViewHolder(child);
if(holder instanceof MediaGridStatusDisplayItem.Holder imgHolder){
if(!imgHolder.getItem().status.spoilerRevealed){
if(TextUtils.isEmpty(imgHolder.getItem().status.spoilerText)){
int listWidth=getListWidthForMediaLayout();
int width=Math.min(listWidth, UiUtils.MAX_WIDTH);
if(currentMediaHiddenLayoutsWidth!=width)
rebuildMediaHiddenLayouts(width-V.dp(32));
c.save();
float totalHeight;
boolean hiddenByAuthor=imgHolder.getItem().status.sensitive;
if(hiddenByAuthor)
totalHeight=mediaHiddenTitleLayout.getHeight()+mediaHiddenTextLayout.getHeight()+V.dp(8);
else
totalHeight=tapToRevealTextLayout.getHeight();
c.translate(child.getX()+V.dp(16), child.getY()+child.getHeight()/2f-totalHeight/2f);
if(hiddenByAuthor){
mediaHiddenTitleLayout.draw(c);
c.translate(0, mediaHiddenTitleLayout.getHeight()+V.dp(8));
mediaHiddenTextLayout.draw(c);
}else{
tapToRevealTextLayout.draw(c);
}
c.restore();
}
}
}
}
}
private void rebuildMediaHiddenLayouts(int width){
currentMediaHiddenLayoutsWidth=width;
String title=getString(R.string.sensitive_content);
TextPaint titlePaint=new TextPaint(Paint.ANTI_ALIAS_FLAG);
titlePaint.setColor(UiUtils.getThemeColor(getContext(), R.attr.colorGray50));
titlePaint.setTextSize(V.dp(22));
titlePaint.setTypeface(mediumTypeface);
mediaHiddenTitleLayout=StaticLayout.Builder.obtain(title, 0, title.length(), titlePaint, width)
.setAlignment(Layout.Alignment.ALIGN_CENTER)
.build();
String tapToReveal=getString(R.string.tap_to_reveal);
tapToRevealTextLayout=StaticLayout.Builder.obtain(tapToReveal, 0, tapToReveal.length(), titlePaint, width)
.setAlignment(Layout.Alignment.ALIGN_CENTER)
.build();
TextPaint textPaint=new TextPaint(Paint.ANTI_ALIAS_FLAG);
textPaint.setColor(UiUtils.getThemeColor(getContext(), R.attr.colorGray200));
textPaint.setTextSize(V.dp(16));
String text=getString(R.string.sensitive_content_explain);
mediaHiddenTextLayout=StaticLayout.Builder.obtain(text, 0, text.length(), textPaint, width)
.setAlignment(Layout.Alignment.ALIGN_CENTER)
.setLineSpacing(V.dp(5), 1f)
.build();
return false;
}
}
}

View File

@ -5,7 +5,8 @@ import android.net.Uri;
import org.joinmastodon.android.R;
import org.joinmastodon.android.api.requests.statuses.GetBookmarkedStatuses;
import org.joinmastodon.android.model.Filter;
import org.joinmastodon.android.events.RemoveAccountPostsEvent;
import org.joinmastodon.android.model.FilterContext;
import org.joinmastodon.android.model.HeaderPaginationList;
import org.joinmastodon.android.model.Status;
@ -39,8 +40,13 @@ public class BookmarkedStatusListFragment extends StatusListFragment{
}
@Override
protected Filter.FilterContext getFilterContext() {
return Filter.FilterContext.ACCOUNT;
protected void onRemoveAccountPostsEvent(RemoveAccountPostsEvent ev){
// no-op
}
@Override
protected FilterContext getFilterContext() {
return FilterContext.ACCOUNT;
}
@Override

View File

@ -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<Build.VERSION_CODES.Q)
betterSpan=new BulletSpan(V.dp(10), UiUtils.getThemeColor(themeWrapper, R.attr.colorM3OnSurface));
else
betterSpan=new BulletSpan(V.dp(10), UiUtils.getThemeColor(themeWrapper, R.attr.colorM3OnSurface), V.dp(1.5f));
msg.setSpan(betterSpan, msg.getSpanStart(span), msg.getSpanEnd(span), msg.getSpanFlags(span));
msg.removeSpan(span);
}
new M3AlertDialogBuilder(themeWrapper)
.setTitle(R.string.what_is_alt_text)
.setMessage(msg)
.setPositiveButton(R.string.ok, null)
.show();
}
return true;
}
private void onSaveClick(View v){
new UpdateAttachment(attachmentID, edit.getText().toString().trim())
.setCallback(new Callback<>(){
@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();
}
}

View File

@ -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<TimelineDefinition> implements ScrollableToTop {
public class EditTimelinesFragment extends MastodonRecyclerFragment<TimelineDefinition> implements ScrollableToTop {
private String accountID;
private TimelinesAdapter adapter;
private final ItemTouchHelper itemTouchHelper;
@ -121,7 +122,7 @@ public class EditTimelinesFragment extends RecyclerFragment<TimelineDefinition>
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<TimelineDefinition>
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<TimelineDefinition>
}
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<TimelineDefinition>
@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<TimelineDefinition>
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<TimelineDefinition>
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<TimelineDefinition>
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<TimelineDefinition>
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);
}
}

View File

@ -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

View File

@ -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<Hashtag>{
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<StatusDisplayItem> 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
}
}

View File

@ -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<FollowRequestsListFragment.AccountWrapper> implements ScrollableToTop, ProvidesAssistContent.ProvidesWebUri {
public class FollowRequestsListFragment extends MastodonRecyclerFragment<FollowRequestsListFragment.AccountWrapper> implements ScrollableToTop, ProvidesAssistContent.ProvidesWebUri {
private String accountID;
private Map<String, Relationship> relationships=Collections.emptyMap();
private GetAccountRelationships relationshipsRequest;
@ -254,7 +254,7 @@ public class FollowRequestsListFragment extends RecyclerFragment<FollowRequestsL
postsCount.setText(UiUtils.abbreviateNumber(item.account.statusesCount));
followersLabel.setText(getResources().getQuantityString(R.plurals.followers, (int)Math.min(999, item.account.followersCount)));
followingLabel.setText(getResources().getQuantityString(R.plurals.following, (int)Math.min(999, item.account.followingCount)));
postsLabel.setText(getResources().getQuantityString(R.plurals.posts, (int)Math.min(999, item.account.statusesCount)));
postsLabel.setText(getResources().getQuantityString(R.plurals.x_posts, (int)(item.account.statusesCount%1000), item.account.statusesCount));
followersCount.setVisibility(item.account.followersCount < 0 ? View.GONE : View.VISIBLE);
followersLabel.setVisibility(item.account.followersCount < 0 ? View.GONE : View.VISIBLE);
followingCount.setVisibility(item.account.followingCount < 0 ? View.GONE : View.VISIBLE);
@ -278,7 +278,7 @@ public class FollowRequestsListFragment extends RecyclerFragment<FollowRequestsL
actionWrap.setVisibility(View.VISIBLE);
acceptWrap.setVisibility(View.GONE);
rejectWrap.setVisibility(View.GONE);
UiUtils.setRelationshipToActionButton(relationship, actionButton);
UiUtils.setRelationshipToActionButtonM3(relationship, actionButton);
}
}
@ -313,6 +313,7 @@ public class FollowRequestsListFragment extends RecyclerFragment<FollowRequestsL
private void onFollowRequestButtonClick(View v) {
itemView.setHasTransientState(true);
UiUtils.handleFollowRequest((Activity) v.getContext(), item.account, accountID, null, v == acceptButton, relationship, rel -> {
if(getContext()==null) return;
itemView.setHasTransientState(false);
relationships.put(item.account.id, rel);
RecyclerView.Adapter<? extends RecyclerView.ViewHolder> adapter = getBindingAdapter();
@ -328,6 +329,7 @@ public class FollowRequestsListFragment extends RecyclerFragment<FollowRequestsL
private void onActionButtonClick(View v){
itemView.setHasTransientState(true);
UiUtils.performAccountAction(getActivity(), item.account, accountID, relationship, actionButton, this::setActionProgressVisible, rel->{
if(getContext()==null) return;
itemView.setHasTransientState(false);
relationships.put(item.account.id, rel);
rebind();

View File

@ -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<Hashtag> implements ScrollableToTop, ProvidesAssistContent.ProvidesWebUri {
public class FollowedHashtagsFragment extends MastodonRecyclerFragment<Hashtag> implements ScrollableToTop, ProvidesAssistContent.ProvidesWebUri {
private String nextMaxID;
private String accountID;
@ -47,7 +47,7 @@ public class FollowedHashtagsFragment extends RecyclerFragment<Hashtag> 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

View File

@ -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<Instance> getInstance() {
return getSession().getInstance();
}
default AccountLocalPreferences getLocalPrefs() {
return AccountSessionManager.get(getAccountID()).getLocalPreferences();
}
}

View File

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

View File

@ -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<Status> 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

View File

@ -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<tabBar.getChildCount(); i++){
ViewGroup f=(ViewGroup) tabBar.getChildAt(i);
f.setBackgroundResource(R.drawable.bg_tabbar_tab_ripple);
}
});
tabBar.findViewById(R.id.tab_profile).setBackgroundResource(R.drawable.bg_tab_profile);
}
tabBarAvatar=tabBar.findViewById(R.id.tab_profile_ava);
tabBarAvatar.setOutlineProvider(OutlineProviders.OVAL);
tabBarAvatar.setClipToOutline(true);
Account self=AccountSessionManager.getInstance().getAccount(accountID).self;
ViewImageLoader.load(tabBarAvatar, null, new UrlImageLoaderRequest(self.avatar, V.dp(28), V.dp(28)));
ViewImageLoader.loadWithoutAnimation(tabBarAvatar, null, new UrlImageLoaderRequest(self.avatar, V.dp(24), V.dp(24)));
notificationTabIcon=content.findViewById(R.id.tab_notifications);
updateNotificationBadge();
notificationsBadge=tabBar.findViewById(R.id.notifications_badge);
notificationsBadge.setVisibility(View.GONE);
if(savedInstanceState==null){
getChildFragmentManager().beginTransaction()
.add(me.grishka.appkit.R.id.fragment_wrap, homeTabFragment)
.add(me.grishka.appkit.R.id.fragment_wrap, searchFragment).hide(searchFragment)
.add(me.grishka.appkit.R.id.fragment_wrap, discoverFragment).hide(discoverFragment)
.add(me.grishka.appkit.R.id.fragment_wrap, notificationsFragment).hide(notificationsFragment)
.add(me.grishka.appkit.R.id.fragment_wrap, profileFragment).hide(profileFragment)
.commit();
@ -165,7 +173,7 @@ public class HomeFragment extends AppKitFragment implements OnBackPressedListene
super.onViewStateRestored(savedInstanceState);
if(savedInstanceState==null) return;
homeTabFragment=(HomeTabFragment) getChildFragmentManager().getFragment(savedInstanceState, "homeTabFragment");
searchFragment=(DiscoverFragment) getChildFragmentManager().getFragment(savedInstanceState, "searchFragment");
discoverFragment=(DiscoverFragment) getChildFragmentManager().getFragment(savedInstanceState, "searchFragment");
notificationsFragment=(NotificationsFragment) getChildFragmentManager().getFragment(savedInstanceState, "notificationsFragment");
profileFragment=(ProfileFragment) getChildFragmentManager().getFragment(savedInstanceState, "profileFragment");
currentTab=savedInstanceState.getInt("selectedTab");
@ -173,7 +181,7 @@ public class HomeFragment extends AppKitFragment implements OnBackPressedListene
Fragment current=fragmentForTab(currentTab);
getChildFragmentManager().beginTransaction()
.hide(homeTabFragment)
.hide(searchFragment)
.hide(discoverFragment)
.hide(notificationsFragment)
.hide(profileFragment)
.show(current)
@ -189,7 +197,7 @@ public class HomeFragment extends AppKitFragment implements OnBackPressedListene
@Override
public boolean wantsLightStatusBar(){
return currentTab!=R.id.tab_profile && !UiUtils.isDarkTheme();
return !UiUtils.isDarkTheme();
}
@Override
@ -201,14 +209,14 @@ public class HomeFragment extends AppKitFragment implements OnBackPressedListene
public void onApplyWindowInsets(WindowInsets insets){
if(Build.VERSION.SDK_INT>=27){
int inset=insets.getSystemWindowInsetBottom();
tabBarWrap.setPadding(0, 0, 0, inset>0 ? Math.max(inset, V.dp(36)) : 0);
tabBarWrap.setPadding(0, 0, 0, inset>0 ? Math.max(inset, V.dp(24)) : 0);
super.onApplyWindowInsets(insets.replaceSystemWindowInsets(insets.getSystemWindowInsetLeft(), 0, insets.getSystemWindowInsetRight(), 0));
}else{
super.onApplyWindowInsets(insets.replaceSystemWindowInsets(insets.getSystemWindowInsetLeft(), 0, insets.getSystemWindowInsetRight(), insets.getSystemWindowInsetBottom()));
}
WindowInsets topOnlyInsets=insets.replaceSystemWindowInsets(0, insets.getSystemWindowInsetTop(), 0, 0);
homeTabFragment.onApplyWindowInsets(topOnlyInsets);
searchFragment.onApplyWindowInsets(topOnlyInsets);
discoverFragment.onApplyWindowInsets(topOnlyInsets);
notificationsFragment.onApplyWindowInsets(topOnlyInsets);
profileFragment.onApplyWindowInsets(topOnlyInsets);
}
@ -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> 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<Notification> 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<Notification>[] 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<List<Notification>> 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<Notification> 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

View File

@ -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<Integer, ListTimeline> listItems = new HashMap<>();
private final Map<Integer, Hashtag> hashtagsItems = new HashMap<>();
private List<TimelineDefinition> timelineDefinitions;
private List<TimelineDefinition> 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;
}

View File

@ -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<Status> filterPosts(List<Status> 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<Status> 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<Status> 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

View File

@ -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<Status> 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

View File

@ -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<ListTimeline> implements ScrollableToTop, ProvidesAssistContent.ProvidesWebUri {
public class ListsFragment extends MastodonRecyclerFragment<ListTimeline> implements ScrollableToTop, ProvidesAssistContent.ProvidesWebUri {
private String accountID;
private String profileAccountId;
private final HashMap<String, Boolean> userInListBefore = new HashMap<>();
@ -80,7 +80,7 @@ public class ListsFragment extends RecyclerFragment<ListTimeline> 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

View File

@ -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<T> extends BaseRecyclerFragment<T>{
protected ElevationOnScrollListener elevationOnScrollListener;
public MastodonRecyclerFragment(int perPage){
super(perPage);
}
public MastodonRecyclerFragment(int layout, int perPage){
super(layout, perPage);
}
protected List<View> 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<T> getData() {
return data;
}
public static void setRefreshLayoutColors(SwipeRefreshLayout l) {
List<Integer> 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));
}
}

View File

@ -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);

View File

@ -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);

View File

@ -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<Notification>{
public class NotificationsListFragment extends BaseStatusListFragment<Notification> {
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<Notificati
public void onCreate(Bundle savedInstanceState){
super.onCreate(savedInstanceState);
E.register(this);
if(savedInstanceState!=null){
onlyMentions=savedInstanceState.getBoolean("onlyMentions", false);
onlyPosts=savedInstanceState.getBoolean("onlyPosts", false);
}
if (onlyPosts) {
bannerHelper=new DiscoverInfoBannerHelper(DiscoverInfoBannerHelper.BannerType.POST_NOTIFICATIONS, accountID);
}
}
@Override
@ -77,59 +81,35 @@ public class NotificationsListFragment extends BaseStatusListFragment<Notificati
super.onAttach(activity);
onlyMentions=getArguments().getBoolean("onlyMentions", false);
onlyPosts=getArguments().getBoolean("onlyPosts", false);
}
@Override
public void onRefresh() {
super.onRefresh();
if (getParentFragment() instanceof NotificationsFragment notificationsFragment) {
notificationsFragment.refreshFollowRequestsBadge();
}
setTitle(R.string.notifications);
}
@Override
protected List<StatusDisplayItem> 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<StatusDisplayItem> 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<StatusDisplayItem> 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<StatusDisplayItem> 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 BaseStatusListFragment<Notificati
protected void doLoadData(int offset, int count){
AccountSessionManager.getInstance()
.getAccount(accountID).getCacheController()
.getNotifications(offset>0 ? 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<List<Notification>> result){
if (getActivity() == null) return;
if(refreshing)
relationships.clear();
public void onSuccess(PaginatedResponse<List<Notification>> result){
if(getActivity()==null)
return;
maxID=result.maxID;
onDataLoaded(result.items.stream().filter(n->n.type!=null).collect(Collectors.toList()), !result.items.isEmpty());
Set<String> 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<list.getChildCount();i++){
RecyclerView.ViewHolder holder=list.getChildViewHolder(list.getChildAt(i));
if(holder instanceof AccountCardStatusDisplayItem.Holder accountHolder)
accountHolder.rebind();
protected void onShown(){
super.onShown();
if(!dataLoading){
if(onlyMentions){
refresh();
}else{
reloadingFromCache=true;
refresh();
}
}
}
@Override
protected void onShown(){
super.onShown();
// if(!getArguments().getBoolean("noAutoLoad") && !loaded && !dataLoading)
// loadData();
protected void onHidden(){
super.onHidden();
resetUnreadBackground();
}
@Override
@ -205,7 +171,50 @@ public class NotificationsListFragment extends BaseStatusListFragment<Notificati
public void onViewCreated(View view, Bundle savedInstanceState){
super.onViewCreated(view, savedInstanceState);
list.addItemDecoration(new InsetStatusItemDecoration(this));
if (onlyPosts) bannerHelper.maybeAddBanner(contentWrap);
list.addItemDecoration(new RecyclerView.ItemDecoration(){
private Paint paint=new Paint();
private Rect tmpRect=new Rect();
{
paint.setColor(UiUtils.getThemeColor(getActivity(), R.attr.colorM3SurfaceVariant));
}
@Override
public void onDraw(@NonNull Canvas c, @NonNull RecyclerView parent, @NonNull RecyclerView.State state){
if (getParentFragment() instanceof NotificationsFragment nf) {
if(TextUtils.isEmpty(nf.unreadMarker))
return;
for(int i=0;i<parent.getChildCount();i++){
View child=parent.getChildAt(i);
if(parent.getChildViewHolder(child) instanceof StatusDisplayItem.Holder<?> 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<View> getViewsForElevationEffect(){
if (getParentFragment() instanceof NotificationsFragment nf) {
ArrayList<View> 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<Notificati
if (n.status == null) continue;
if(n.status.getContentStatus().id.equals(ev.id)){
n.status.getContentStatus().update(ev);
AccountSessionManager.get(accountID).getCacheController().updateNotification(n);
for(int i=0;i<list.getChildCount();i++){
RecyclerView.ViewHolder holder=list.getChildViewHolder(list.getChildAt(i));
if(holder instanceof FooterStatusDisplayItem.Holder footer && footer.getItem().status==n.status.getContentStatus()){
@ -252,6 +262,7 @@ public class NotificationsListFragment extends BaseStatusListFragment<Notificati
if (n.status == null) continue;
if(n.status.getContentStatus().id.equals(ev.id)){
n.status.getContentStatus().update(ev);
AccountSessionManager.get(accountID).getCacheController().updateNotification(n);
}
}
}
@ -289,6 +300,40 @@ public class NotificationsListFragment extends BaseStatusListFragment<Notificati
adapter.notifyItemRangeRemoved(index, lastIndex-index);
}
@Override
protected boolean needDividerForExtraItem(View child, View bottomSibling, RecyclerView.ViewHolder holder, RecyclerView.ViewHolder siblingHolder){
return super.needDividerForExtraItem(child, bottomSibling, holder, siblingHolder) || (siblingHolder!=null && siblingHolder.getAbsoluteAdapterPosition()>=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()

View File

@ -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<TimelineDefinition> pinnedTimelines;
protected List<TimelineDefinition> 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);
}

View File

@ -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

View File

@ -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<AccountField> 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<AccountField> 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<parent.getChildCount();i++){
View item=parent.getChildAt(i);
int pos=parent.getChildAdapterPosition(item);
int draggedPos=draggedViewHolder==null ? -1 : draggedViewHolder.getAbsoluteAdapterPosition();
if(pos<adapter.getItemCount()-1 && pos!=draggedPos && pos!=draggedPos-1){
float y=item.getY()+item.getHeight();
dividerPaint.setAlpha(Math.round(255*item.getAlpha()));
c.drawLine(item.getLeft(), y, item.getRight(), y, dividerPaint);
}
}
}
});
return list;
}
@ -103,12 +84,17 @@ public class ProfileAboutFragment extends Fragment implements WindowInsetsAwareF
fields=editableFields;
adapter.notifyDataSetChanged();
dragHelper.attachToRecyclerView(list);
editDirty=false;
}
public List<AccountField> 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<AccountField>{
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

View File

@ -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<T> extends BaseRecyclerFragment<T> {
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<Integer> 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);
}
}

View File

@ -80,7 +80,7 @@ public class ScheduledStatusListFragment extends BaseStatusListFragment<Schedule
@Override
protected List<StatusDisplayItem> 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

View File

@ -7,20 +7,27 @@ import android.view.View;
import android.view.ViewGroup;
import android.view.ViewTreeObserver;
import android.view.WindowInsets;
import android.widget.Button;
import android.widget.ProgressBar;
import org.joinmastodon.android.MastodonApp;
import org.joinmastodon.android.R;
import org.joinmastodon.android.api.requests.catalog.GetCatalogDefaultInstances;
import org.joinmastodon.android.api.requests.instance.GetInstance;
import org.joinmastodon.android.fragments.onboarding.InstanceCatalogSignupFragment;
import org.joinmastodon.android.fragments.onboarding.InstanceChooserLoginFragment;
import org.joinmastodon.android.fragments.onboarding.InstanceRulesFragment;
import org.joinmastodon.android.model.Instance;
import org.joinmastodon.android.model.catalog.CatalogDefaultInstance;
import org.joinmastodon.android.ui.InterpolatingMotionEffect;
import org.joinmastodon.android.ui.M3AlertDialogBuilder;
import org.joinmastodon.android.ui.utils.UiUtils;
import org.joinmastodon.android.ui.views.ProgressBarButton;
import org.joinmastodon.android.ui.views.SizeListenerFrameLayout;
import org.parceler.Parcels;
import java.util.List;
import java.util.concurrent.ThreadLocalRandom;
import androidx.annotation.Nullable;
import me.grishka.appkit.Nav;
import me.grishka.appkit.api.Callback;
@ -37,11 +44,16 @@ public class SplashFragment extends AppKitFragment{
private View artContainer, blueFill, greenFill;
private InterpolatingMotionEffect motionEffect;
private View artClouds, artPlaneElephant, artRightHill, artLeftHill, artCenterHill;
private ProgressBarButton defaultServerButton;
private ProgressBar defaultServerProgress;
private String chosenDefaultServer=DEFAULT_SERVER;
private boolean loadingDefaultServer;
@Override
public void onCreate(Bundle savedInstanceState){
super.onCreate(savedInstanceState);
motionEffect=new InterpolatingMotionEffect(MastodonApp.context);
loadAndChooseDefaultServer();
}
@Nullable
@ -50,9 +62,14 @@ public class SplashFragment extends AppKitFragment{
contentView=(SizeListenerFrameLayout) inflater.inflate(R.layout.fragment_splash, container, false);
contentView.findViewById(R.id.btn_get_started).setOnClickListener(this::onButtonClick);
contentView.findViewById(R.id.btn_log_in).setOnClickListener(this::onButtonClick);
Button joinDefault=contentView.findViewById(R.id.btn_join_default_server);
joinDefault.setText(getString(R.string.join_default_server, DEFAULT_SERVER));
joinDefault.setOnClickListener(this::onJoinDefaultServerClick);
defaultServerButton=contentView.findViewById(R.id.btn_join_default_server);
defaultServerButton.setText(getString(R.string.join_default_server, chosenDefaultServer));
defaultServerButton.setOnClickListener(this::onJoinDefaultServerClick);
defaultServerProgress=contentView.findViewById(R.id.action_progress);
if(loadingDefaultServer){
defaultServerButton.setTextVisible(false);
defaultServerProgress.setVisibility(View.VISIBLE);
}
contentView.findViewById(R.id.btn_learn_more).setOnClickListener(this::onLearnMoreClick);
artClouds=contentView.findViewById(R.id.art_clouds);
@ -96,12 +113,22 @@ public class SplashFragment extends AppKitFragment{
}
private void onJoinDefaultServerClick(View v){
if(loadingDefaultServer)
return;
new GetInstance()
.setCallback(new Callback<>(){
@Override
public void onSuccess(Instance result){
if(getActivity()==null)
return;
if(!result.registrations){
new M3AlertDialogBuilder(getActivity())
.setTitle(R.string.error)
.setMessage(R.string.instance_signup_closed)
.setPositiveButton(R.string.ok, null)
.show();
return;
}
Bundle args=new Bundle();
args.putParcelable("instance", Parcels.wrap(result));
Nav.go(getActivity(), InstanceRulesFragment.class, args);
@ -115,7 +142,7 @@ public class SplashFragment extends AppKitFragment{
}
})
.wrapProgress(getActivity(), R.string.loading_instance, true)
.execNoAuth(DEFAULT_SERVER);
.execNoAuth(chosenDefaultServer);
}
private void onLearnMoreClick(View v){
@ -168,4 +195,54 @@ public class SplashFragment extends AppKitFragment{
super.onHidden();
motionEffect.deactivate();
}
private void loadAndChooseDefaultServer(){
loadingDefaultServer=true;
new GetCatalogDefaultInstances()
.setCallback(new Callback<>(){
@Override
public void onSuccess(List<CatalogDefaultInstance> result){
if(result.isEmpty()){
setChosenDefaultServer(DEFAULT_SERVER);
return;
}
float sum=0f;
for(CatalogDefaultInstance inst:result){
sum+=inst.weight;
}
if(sum<=0)
sum=1f;
for(CatalogDefaultInstance inst:result){
inst.weight/=sum;
}
float rand=ThreadLocalRandom.current().nextFloat();
float prev=0f;
for(CatalogDefaultInstance inst:result){
if(rand>=prev && rand<prev+inst.weight){
setChosenDefaultServer(inst.domain);
return;
}
prev+=inst.weight;
}
// Just in case something didn't add up
setChosenDefaultServer(result.get(result.size()-1).domain);
}
@Override
public void onError(ErrorResponse error){
setChosenDefaultServer(DEFAULT_SERVER);
}
})
.execNoAuth("");
}
private void setChosenDefaultServer(String domain){
chosenDefaultServer=domain;
loadingDefaultServer=false;
if(defaultServerButton!=null && getActivity()!=null){
defaultServerButton.setTextVisible(true);
defaultServerProgress.setVisibility(View.GONE);
defaultServerButton.setText(getString(R.string.join_default_server, chosenDefaultServer));
}
}
}

View File

@ -7,7 +7,7 @@ import android.view.View;
import org.joinmastodon.android.R;
import org.joinmastodon.android.api.requests.statuses.GetStatusEditHistory;
import org.joinmastodon.android.model.Filter;
import org.joinmastodon.android.model.FilterContext;
import org.joinmastodon.android.model.Status;
import org.joinmastodon.android.ui.displayitems.ReblogOrReplyLineStatusDisplayItem;
import org.joinmastodon.android.ui.displayitems.StatusDisplayItem;
@ -57,7 +57,7 @@ public class StatusEditHistoryFragment extends StatusListFragment{
@Override
protected List<StatusDisplayItem> buildDisplayItems(Status s){
List<StatusDisplayItem> items=StatusDisplayItem.buildItems(this, s, accountID, s, knownAccounts, true, false, null, null);
List<StatusDisplayItem> items=StatusDisplayItem.buildItems(this, s, accountID, s, knownAccounts, null, StatusDisplayItem.FLAG_NO_FOOTER | StatusDisplayItem.FLAG_INSET);
int idx=data.indexOf(s);
if(idx>=0){
String date=UiUtils.DATE_TIME_FORMATTER.format(s.createdAt.atZone(ZoneId.systemDefault()));
@ -160,7 +160,7 @@ public class StatusEditHistoryFragment extends StatusListFragment{
}
@Override
protected Filter.FilterContext getFilterContext() {
protected FilterContext getFilterContext() {
return null;
}

View File

@ -1,6 +1,5 @@
package org.joinmastodon.android.fragments;
import android.app.assist.AssistContent;
import android.content.res.Configuration;
import android.os.Bundle;
@ -8,13 +7,14 @@ import com.squareup.otto.Subscribe;
import org.joinmastodon.android.E;
import org.joinmastodon.android.GlobalUserPreferences;
import org.joinmastodon.android.api.session.AccountSessionManager;
import org.joinmastodon.android.events.PollUpdatedEvent;
import org.joinmastodon.android.events.RemoveAccountPostsEvent;
import org.joinmastodon.android.events.StatusCountersUpdatedEvent;
import org.joinmastodon.android.events.StatusCreatedEvent;
import org.joinmastodon.android.events.StatusDeletedEvent;
import org.joinmastodon.android.events.StatusUpdatedEvent;
import org.joinmastodon.android.model.Filter;
import org.joinmastodon.android.model.FilterContext;
import org.joinmastodon.android.model.Status;
import org.joinmastodon.android.ui.displayitems.ExtendedFooterStatusDisplayItem;
import org.joinmastodon.android.ui.displayitems.FooterStatusDisplayItem;
@ -35,10 +35,10 @@ public abstract class StatusListFragment extends BaseStatusListFragment<Status>
protected List<StatusDisplayItem> buildDisplayItems(Status s){
boolean addFooter = !GlobalUserPreferences.spectatorMode ||
(this instanceof ThreadFragment t && s.id.equals(t.mainStatus.id));
return StatusDisplayItem.buildItems(this, s, accountID, s, knownAccounts, false, addFooter, null, getFilterContext());
return StatusDisplayItem.buildItems(this, s, accountID, s, knownAccounts, getFilterContext(), addFooter ? 0 : StatusDisplayItem.FLAG_NO_FOOTER);
}
protected abstract Filter.FilterContext getFilterContext();
protected abstract FilterContext getFilterContext();
@Override
protected void addAccountToKnown(Status s){
@ -65,6 +65,11 @@ public abstract class StatusListFragment extends BaseStatusListFragment<Status>
Status status=getContentStatusByID(id);
if(status==null)
return;
Status parentStatus = getStatusByID(id);
if (parentStatus != status) {
status.spoilerRevealed = parentStatus.spoilerRevealed;
status.sensitiveRevealed = parentStatus.sensitiveRevealed;
}
status.filterRevealed = true;
Bundle args=new Bundle();
args.putString("account", accountID);
@ -74,26 +79,26 @@ public abstract class StatusListFragment extends BaseStatusListFragment<Status>
Nav.go(getActivity(), ThreadFragment.class, args);
}
protected void onStatusCreated(StatusCreatedEvent ev){}
protected void onStatusCreated(Status status){}
protected void onStatusUpdated(StatusUpdatedEvent ev){
protected void onStatusUpdated(Status status){
ArrayList<Status> statusesForDisplayItems=new ArrayList<>();
for(int i=0;i<data.size();i++){
Status s=data.get(i);
if(s.reblog!=null && s.reblog.id.equals(ev.status.id)){
s.reblog=ev.status;
if(s.reblog!=null && s.reblog.id.equals(status.id)){
s.reblog=status.clone();
statusesForDisplayItems.add(s);
}else if(s.id.equals(ev.status.id)){
data.set(i, ev.status);
statusesForDisplayItems.add(ev.status);
}else if(s.id.equals(status.id)){
data.set(i, status);
statusesForDisplayItems.add(status);
}
}
for(int i=0;i<preloadedData.size();i++){
Status s=preloadedData.get(i);
if(s.reblog!=null && s.reblog.id.equals(ev.status.id)){
s.reblog=ev.status;
}else if(s.id.equals(ev.status.id)){
preloadedData.set(i, ev.status);
if(s.reblog!=null && s.reblog.id.equals(status.id)){
s.reblog=status.clone();
}else if(s.id.equals(status.id)){
preloadedData.set(i, status);
}
}
@ -211,6 +216,7 @@ public abstract class StatusListFragment extends BaseStatusListFragment<Status>
for(Status s:data){
if(s.getContentStatus().id.equals(ev.id)){
s.getContentStatus().update(ev);
AccountSessionManager.get(accountID).getCacheController().updateStatus(s);
for(int i=0;i<list.getChildCount();i++){
RecyclerView.ViewHolder holder=list.getChildViewHolder(list.getChildAt(i));
if(holder instanceof FooterStatusDisplayItem.Holder footer && footer.getItem().status==s.getContentStatus()){
@ -224,6 +230,7 @@ public abstract class StatusListFragment extends BaseStatusListFragment<Status>
for(Status s:preloadedData){
if(s.getContentStatus().id.equals(ev.id)){
s.getContentStatus().update(ev);
AccountSessionManager.get(accountID).getCacheController().updateStatus(s);
}
}
}
@ -242,12 +249,12 @@ public abstract class StatusListFragment extends BaseStatusListFragment<Status>
public void onStatusCreated(StatusCreatedEvent ev){
if(!ev.accountID.equals(accountID))
return;
StatusListFragment.this.onStatusCreated(ev);
StatusListFragment.this.onStatusCreated(ev.status.clone());
}
@Subscribe
public void onStatusUpdated(StatusUpdatedEvent ev){
StatusListFragment.this.onStatusUpdated(ev);
StatusListFragment.this.onStatusUpdated(ev.status);
}
@Subscribe
@ -257,7 +264,7 @@ public abstract class StatusListFragment extends BaseStatusListFragment<Status>
for(Status status:data){
Status contentStatus=status.getContentStatus();
if(contentStatus.poll!=null && contentStatus.poll.id.equals(ev.poll.id)){
updatePoll(status.id, status, ev.poll);
updatePoll(status.id, contentStatus, ev.poll);
}
}
}

View File

@ -17,7 +17,7 @@ import org.joinmastodon.android.events.StatusCountersUpdatedEvent;
import org.joinmastodon.android.events.StatusCreatedEvent;
import org.joinmastodon.android.events.StatusUpdatedEvent;
import org.joinmastodon.android.model.Account;
import org.joinmastodon.android.model.Filter;
import org.joinmastodon.android.model.FilterContext;
import org.joinmastodon.android.model.Status;
import org.joinmastodon.android.model.StatusContext;
import org.joinmastodon.android.ui.BetterItemAnimator;
@ -152,6 +152,26 @@ public class ThreadFragment extends StatusListFragment implements ProvidesAssist
}).exec(accountID);
}
private void restoreStatusStates(List<Status> newData, Map<String, Status> oldData) {
for (Status s : newData) {
if (s == mainStatus) continue;
Status oldStatus = oldData == null ? null : oldData.get(s.id);
// restore previous spoiler/filter revealed states when refreshing
if (oldStatus != null) {
s.spoilerRevealed = oldStatus.spoilerRevealed;
s.sensitiveRevealed = oldStatus.sensitiveRevealed;
s.filterRevealed = oldStatus.filterRevealed;
}
if (GlobalUserPreferences.autoRevealEqualSpoilers != AutoRevealMode.NEVER &&
s.spoilerText != null &&
s.spoilerText.equals(mainStatus.spoilerText)) {
if (GlobalUserPreferences.autoRevealEqualSpoilers == AutoRevealMode.DISCUSSIONS || Objects.equals(mainStatus.account.id, s.account.id)) {
s.spoilerRevealed = mainStatus.spoilerRevealed;
}
}
}
}
protected void maybeApplyContext() {
if (!transitionFinished || result == null || getContext() == null) return;
Map<String, Status> oldData = null;
@ -170,6 +190,8 @@ public class ThreadFragment extends StatusListFragment implements ProvidesAssist
result.descendants=filterStatuses(result.descendants);
result.ancestors=filterStatuses(result.ancestors);
restoreStatusStates(result.descendants, oldData);
restoreStatusStates(result.ancestors, oldData);
for (NeighborAncestryInfo i : mapNeighborhoodAncestry(mainStatus, result)) {
ancestryMap.put(i.status.id, i);
@ -178,8 +200,10 @@ public class ThreadFragment extends StatusListFragment implements ProvidesAssist
if(footerProgress!=null)
footerProgress.setVisibility(View.GONE);
data.addAll(result.descendants);
int prevCount=displayItems.size();
onAppendItems(result.descendants);
int count=displayItems.size();
if(!refreshing)
adapter.notifyItemRangeInserted(prevCount, count-prevCount);
@ -190,22 +214,6 @@ public class ThreadFragment extends StatusListFragment implements ProvidesAssist
count--;
}
for (Status s : data) {
Status oldStatus = oldData == null ? null : oldData.get(s.id);
// restore previous spoiler/filter revealed states when refreshing
if (oldStatus != null) {
s.spoilerRevealed = oldStatus.spoilerRevealed;
s.filterRevealed = oldStatus.filterRevealed;
} else if (GlobalUserPreferences.autoRevealEqualSpoilers != AutoRevealMode.NEVER &&
s.spoilerText != null &&
s.spoilerText.equals(mainStatus.spoilerText) &&
mainStatus.spoilerRevealed) {
if (GlobalUserPreferences.autoRevealEqualSpoilers == AutoRevealMode.DISCUSSIONS || Objects.equals(mainStatus.account.id, s.account.id)) {
s.spoilerRevealed = true;
}
}
}
dataLoaded();
if(refreshing){
refreshDone();
@ -222,13 +230,13 @@ public class ThreadFragment extends StatusListFragment implements ProvidesAssist
result = null;
}
protected Object maybeApplyMainStatus() {
if (updatedStatus == null || !contextInitiallyRendered) return null;
// restore revealed states for main status because it gets updated after doLoadData
updatedStatus.filterRevealed = mainStatus.filterRevealed;
updatedStatus.spoilerRevealed = mainStatus.spoilerRevealed;
updatedStatus.sensitiveRevealed = mainStatus.sensitiveRevealed;
// returning fired event object to facilitate testing
Object event;
@ -352,9 +360,9 @@ public class ThreadFragment extends StatusListFragment implements ProvidesAssist
});
}
protected void onStatusCreated(StatusCreatedEvent ev){
if (ev.status.inReplyToId == null) return;
Status repliedToStatus = getStatusByID(ev.status.inReplyToId);
protected void onStatusCreated(Status status){
if (status.inReplyToId == null) return;
Status repliedToStatus = getStatusByID(status.inReplyToId);
if (repliedToStatus == null) return;
NeighborAncestryInfo ancestry = ancestryMap.get(repliedToStatus.id);
@ -400,12 +408,12 @@ public class ThreadFragment extends StatusListFragment implements ProvidesAssist
}
// update replied-to status' ancestry
if (ancestry != null) ancestry.descendantNeighbor = ev.status;
if (ancestry != null) ancestry.descendantNeighbor = status;
// add ancestry for newly created status before building its display items
ancestryMap.put(ev.status.id, new NeighborAncestryInfo(ev.status, null, repliedToStatus));
displayItems.addAll(nextDisplayItemsIndex, buildDisplayItems(ev.status));
data.add(nextDataIndex, ev.status);
ancestryMap.put(status.id, new NeighborAncestryInfo(status, null, repliedToStatus));
displayItems.addAll(nextDisplayItemsIndex, buildDisplayItems(status));
data.add(nextDataIndex, status);
adapter.notifyDataSetChanged();
}
@ -426,8 +434,8 @@ public class ThreadFragment extends StatusListFragment implements ProvidesAssist
@Override
protected Filter.FilterContext getFilterContext() {
return Filter.FilterContext.THREAD;
protected FilterContext getFilterContext() {
return FilterContext.THREAD;
}
@Override

View File

@ -1,44 +1,22 @@
package org.joinmastodon.android.fragments.account_list;
import android.annotation.SuppressLint;
import android.app.ProgressDialog;
import android.app.assist.AssistContent;
import android.content.Intent;
import android.content.res.Configuration;
import android.graphics.drawable.Animatable;
import android.graphics.drawable.Drawable;
import android.os.Build;
import android.os.Bundle;
import android.view.Menu;
import android.view.MenuItem;
import android.view.View;
import android.view.ViewGroup;
import android.view.WindowInsets;
import android.widget.Button;
import android.widget.ImageView;
import android.widget.PopupMenu;
import android.widget.TextView;
import android.widget.Toolbar;
import org.joinmastodon.android.GlobalUserPreferences;
import org.joinmastodon.android.R;
import org.joinmastodon.android.api.requests.accounts.GetAccountRelationships;
import org.joinmastodon.android.api.requests.accounts.SetAccountFollowed;
import org.joinmastodon.android.api.session.AccountSessionManager;
import org.joinmastodon.android.fragments.HasAccountID;
import org.joinmastodon.android.fragments.ListsFragment;
import org.joinmastodon.android.fragments.ProfileFragment;
import org.joinmastodon.android.fragments.RecyclerFragment;
import org.joinmastodon.android.fragments.report.ReportReasonChoiceFragment;
import org.joinmastodon.android.model.Account;
import org.joinmastodon.android.fragments.MastodonRecyclerFragment;
import org.joinmastodon.android.model.Relationship;
import org.joinmastodon.android.ui.DividerItemDecoration;
import org.joinmastodon.android.ui.OutlineProviders;
import org.joinmastodon.android.ui.text.HtmlParser;
import org.joinmastodon.android.ui.utils.CustomEmojiHelper;
import org.joinmastodon.android.model.viewmodel.AccountViewModel;
import org.joinmastodon.android.ui.utils.UiUtils;
import org.joinmastodon.android.ui.viewholders.AccountViewHolder;
import org.joinmastodon.android.utils.ProvidesAssistContent;
import org.parceler.Parcels;
import java.util.ArrayList;
import java.util.HashMap;
@ -48,21 +26,16 @@ import java.util.stream.Collectors;
import androidx.annotation.CallSuper;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.recyclerview.widget.RecyclerView;
import me.grishka.appkit.Nav;
import me.grishka.appkit.api.APIRequest;
import me.grishka.appkit.api.Callback;
import me.grishka.appkit.api.ErrorResponse;
import me.grishka.appkit.imageloader.ImageLoaderRecyclerAdapter;
import me.grishka.appkit.imageloader.ImageLoaderViewHolder;
import me.grishka.appkit.imageloader.requests.ImageLoaderRequest;
import me.grishka.appkit.imageloader.requests.UrlImageLoaderRequest;
import me.grishka.appkit.utils.BindableViewHolder;
import me.grishka.appkit.utils.V;
import me.grishka.appkit.views.UsableRecyclerView;
public abstract class BaseAccountListFragment extends RecyclerFragment<BaseAccountListFragment.AccountItem> implements ProvidesAssistContent.ProvidesWebUri {
public abstract class BaseAccountListFragment extends MastodonRecyclerFragment<AccountViewModel> implements ProvidesAssistContent.ProvidesWebUri {
protected HashMap<String, Relationship> relationships=new HashMap<>();
protected String accountID;
protected ArrayList<APIRequest<?>> relationshipsRequests=new ArrayList<>();
@ -71,6 +44,10 @@ public abstract class BaseAccountListFragment extends RecyclerFragment<BaseAccou
super(40);
}
public BaseAccountListFragment(int layout, int perPage){
super(layout, perPage);
}
@Override
public void onCreate(Bundle savedInstanceState){
super.onCreate(savedInstanceState);
@ -78,7 +55,7 @@ public abstract class BaseAccountListFragment extends RecyclerFragment<BaseAccou
}
@Override
protected void onDataLoaded(List<AccountItem> d, boolean more){
protected void onDataLoaded(List<AccountViewModel> d, boolean more){
if(refreshing){
relationships.clear();
}
@ -95,7 +72,7 @@ public abstract class BaseAccountListFragment extends RecyclerFragment<BaseAccou
super.onRefresh();
}
protected void loadRelationships(List<AccountItem> accounts){
protected void loadRelationships(List<AccountViewModel> accounts){
Set<String> ids=accounts.stream().map(ai->ai.account.id).collect(Collectors.toSet());
GetAccountRelationships req=new GetAccountRelationships(ids);
relationshipsRequests.add(req);
@ -132,10 +109,7 @@ public abstract class BaseAccountListFragment extends RecyclerFragment<BaseAccou
@Override
public void onViewCreated(View view, Bundle savedInstanceState){
super.onViewCreated(view, savedInstanceState);
// list.setPadding(0, V.dp(16), 0, V.dp(16));
list.setClipToPadding(false);
list.addItemDecoration(new DividerItemDecoration(getActivity(), R.attr.colorPollVoted, 1,
Math.round(16f + 56f * getResources().getConfiguration().fontScale), 16));
updateToolbar();
}
@ -168,6 +142,8 @@ public abstract class BaseAccountListFragment extends RecyclerFragment<BaseAccou
public void onApplyWindowInsets(WindowInsets insets){
if(Build.VERSION.SDK_INT>=29 && insets.getTappableElementInsets().bottom==0){
list.setPadding(0, V.dp(16), 0, V.dp(16)+insets.getSystemWindowInsetBottom());
emptyView.setPadding(0, 0, 0, insets.getSystemWindowInsetBottom());
progress.setPadding(0, 0, 0, insets.getSystemWindowInsetBottom());
insets=insets.inset(0, 0, 0, insets.getSystemWindowInsetBottom());
}else{
list.setPadding(0, V.dp(16), 0, V.dp(16));
@ -185,6 +161,8 @@ public abstract class BaseAccountListFragment extends RecyclerFragment<BaseAccou
assistContent.setWebUri(getWebUri(getSession().getInstanceUri().buildUpon()));
}
protected void onConfigureViewHolder(AccountViewHolder holder){}
protected class AccountsAdapter extends UsableRecyclerView.Adapter<AccountViewHolder> implements ImageLoaderRecyclerAdapter{
public AccountsAdapter(){
super(imgLoader);
@ -193,7 +171,9 @@ public abstract class BaseAccountListFragment extends RecyclerFragment<BaseAccou
@NonNull
@Override
public AccountViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType){
return new AccountViewHolder();
AccountViewHolder holder=new AccountViewHolder(BaseAccountListFragment.this, parent, relationships);
onConfigureViewHolder(holder);
return holder;
}
@Override
@ -214,225 +194,8 @@ public abstract class BaseAccountListFragment extends RecyclerFragment<BaseAccou
@Override
public ImageLoaderRequest getImageRequest(int position, int image){
AccountItem item=data.get(position);
AccountViewModel item=data.get(position);
return image==0 ? item.avaRequest : item.emojiHelper.getImageRequest(image-1);
}
}
protected class AccountViewHolder extends BindableViewHolder<AccountItem> implements ImageLoaderViewHolder, UsableRecyclerView.Clickable, UsableRecyclerView.LongClickable{
private final TextView name, username;
private final ImageView avatar;
private final Button button;
private final PopupMenu contextMenu;
private final View menuAnchor;
public AccountViewHolder(){
super(getActivity(), R.layout.item_account_list, list);
name=findViewById(R.id.name);
username=findViewById(R.id.username);
avatar=findViewById(R.id.avatar);
button=findViewById(R.id.button);
menuAnchor=findViewById(R.id.menu_anchor);
avatar.setOutlineProvider(OutlineProviders.roundedRect(12));
avatar.setClipToOutline(true);
button.setOnClickListener(this::onButtonClick);
contextMenu=new PopupMenu(getActivity(), menuAnchor);
contextMenu.inflate(R.menu.profile);
contextMenu.setOnMenuItemClickListener(this::onContextMenuItemSelected);
UiUtils.enablePopupMenuIcons(getActivity(), contextMenu);
}
@SuppressLint("SetTextI18n")
@Override
public void onBind(AccountItem item){
name.setText(item.parsedName);
username.setText("@"+ (item.account.isRemote
? item.account.getFullyQualifiedName()
: item.account.acct));
bindRelationship();
}
public void bindRelationship(){
Relationship rel=relationships.get(item.account.id);
if(rel==null || item.account.isRemote || AccountSessionManager.getInstance().isSelf(accountID, item.account)){
button.setVisibility(View.GONE);
}else{
button.setVisibility(View.VISIBLE);
UiUtils.setRelationshipToActionButton(rel, button);
}
}
@Override
public void setImage(int index, Drawable image){
if(index==0){
avatar.setImageDrawable(image);
}else{
item.emojiHelper.setImageDrawable(index-1, image);
name.invalidate();
}
if(image instanceof Animatable a && !a.isRunning())
a.start();
}
@Override
public void clearImage(int index){
setImage(index, null);
}
@Override
public void onClick(){
Bundle args=new Bundle();
args.putString("account", accountID);
if (item.account.isRemote) args.putParcelable("remoteAccount", Parcels.wrap(item.account));
else args.putParcelable("profileAccount", Parcels.wrap(item.account));
Nav.go(getActivity(), ProfileFragment.class, args);
}
@Override
public boolean onLongClick(){
return false;
}
@Override
public boolean onLongClick(float x, float y){
Relationship relationship=relationships.get(item.account.id);
if(relationship==null)
return false;
Menu menu=contextMenu.getMenu();
Account account=item.account;
menu.findItem(R.id.share).setTitle(getString(R.string.share_user, account.getShortUsername()));
MenuItem mute = menu.findItem(R.id.mute);
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.soft_block).setVisible(relationship.followedBy && !relationship.following);
MenuItem hideBoosts=menu.findItem(R.id.hide_boosts);
MenuItem manageUserLists=menu.findItem(R.id.manage_user_lists);
if(relationship.following){
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);
hideBoosts.setVisible(true);
UiUtils.insetPopupMenuIcon(getContext(), hideBoosts);
manageUserLists.setTitle(getString(R.string.sk_lists_with_user, account.getShortUsername()));
manageUserLists.setVisible(true);
}else{
hideBoosts.setVisible(false);
manageUserLists.setVisible(false);
}
menu.findItem(R.id.block_domain).setVisible(false);
menuAnchor.setTranslationX(x);
menuAnchor.setTranslationY(y);
contextMenu.show();
return true;
}
private void onButtonClick(View v){
ProgressDialog progress=new ProgressDialog(getActivity());
progress.setMessage(getString(R.string.loading));
progress.setCancelable(false);
UiUtils.performAccountAction(getActivity(), item.account, accountID, relationships.get(item.account.id), button, progressShown->{
itemView.setHasTransientState(progressShown);
if(progressShown)
progress.show();
else
progress.dismiss();
}, result->{
relationships.put(item.account.id, result);
bindRelationship();
});
}
private boolean onContextMenuItemSelected(MenuItem item){
Relationship relationship=relationships.get(this.item.account.id);
if(relationship==null)
return false;
Account account=this.item.account;
int id=item.getItemId();
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()));
}else if(id==R.id.mute){
UiUtils.confirmToggleMuteUser(getActivity(), accountID, account, relationship.muting, this::updateRelationship);
}else if(id==R.id.block){
UiUtils.confirmToggleBlockUser(getActivity(), accountID, account, relationship.blocking, this::updateRelationship);
}else if(id==R.id.soft_block){
UiUtils.confirmSoftBlockUser(getActivity(), accountID, account, this::updateRelationship);
}else if(id==R.id.report){
Bundle args=new Bundle();
args.putString("account", accountID);
args.putParcelable("reportAccount", Parcels.wrap(account));
Nav.go(getActivity(), ReportReasonChoiceFragment.class, args);
}else if(id==R.id.open_in_browser){
UiUtils.launchWebBrowser(getActivity(), account.url);
}else if(id==R.id.block_domain){
UiUtils.confirmToggleBlockDomain(getActivity(), accountID, account.getDomain(), relationship.domainBlocking, ()->{
relationship.domainBlocking=!relationship.domainBlocking;
bindRelationship();
});
}else if(id==R.id.hide_boosts){
new SetAccountFollowed(account.id, true, !relationship.showingReblogs, relationship.notifying)
.setCallback(new Callback<>(){
@Override
public void onSuccess(Relationship result){
relationships.put(AccountViewHolder.this.item.account.id, result);
if (getActivity() == null) return;
bindRelationship();
}
@Override
public void onError(ErrorResponse error){
error.showToast(getActivity());
}
})
.wrapProgress(getActivity(), R.string.loading, false)
.exec(accountID);
}else if(id==R.id.manage_user_lists){
final Bundle args=new Bundle();
args.putString("account", accountID);
args.putString("profileAccount", account.id);
args.putString("profileDisplayUsername", account.getDisplayUsername());
Nav.go(getActivity(), ListsFragment.class, args);
}
return true;
}
private void updateRelationship(Relationship r){
relationships.put(item.account.id, r);
bindRelationship();
}
}
protected static class AccountItem{
public final Account account;
public final ImageLoaderRequest avaRequest;
public final CustomEmojiHelper emojiHelper;
public final CharSequence parsedName;
public AccountItem(Account account){
this.account=account;
avaRequest=new UrlImageLoaderRequest(GlobalUserPreferences.playGifs ? account.avatar : account.avatarStatic, V.dp(50), V.dp(50));
emojiHelper=new CustomEmojiHelper();
emojiHelper.setText(parsedName=HtmlParser.parseCustomEmoji(account.displayName, account.emojis));
}
@Override
public boolean equals(@Nullable Object obj) {
return obj instanceof AccountItem i && i.account.url.equals(account.url);
}
}
}

View File

@ -0,0 +1,105 @@
package org.joinmastodon.android.fragments.account_list;
import android.net.Uri;
import android.os.Bundle;
import android.text.TextUtils;
import android.view.View;
import org.joinmastodon.android.R;
import org.joinmastodon.android.api.requests.search.GetSearchResults;
import org.joinmastodon.android.model.SearchResults;
import org.joinmastodon.android.model.viewmodel.AccountViewModel;
import org.joinmastodon.android.ui.SearchViewHelper;
import org.joinmastodon.android.ui.utils.UiUtils;
import org.joinmastodon.android.ui.viewholders.AccountViewHolder;
import org.parceler.Parcels;
import java.util.stream.Collectors;
import me.grishka.appkit.Nav;
import me.grishka.appkit.api.SimpleCallback;
public class ComposeAccountSearchFragment extends BaseAccountListFragment{
private String currentQuery;
private boolean resultDelivered;
private SearchViewHelper searchViewHelper;
@Override
public void onCreate(Bundle savedInstanceState){
super.onCreate(savedInstanceState);
setRefreshEnabled(false);
setEmptyText("");
dataLoaded();
}
@Override
public void onViewCreated(View view, Bundle savedInstanceState){
searchViewHelper=new SearchViewHelper(getActivity(), getToolbarContext(), getString(R.string.search_hint));
searchViewHelper.setListeners(this::onQueryChanged, null);
searchViewHelper.addDivider(contentView);
super.onViewCreated(view, savedInstanceState);
view.setBackgroundResource(R.drawable.bg_m3_surface3);
int color=UiUtils.alphaBlendThemeColors(getActivity(), R.attr.colorM3Surface, R.attr.colorM3Primary, 0.11f);
setStatusBarColor(color);
setNavigationBarColor(color);
}
@Override
protected void doLoadData(int offset, int count){
refreshing=true;
currentRequest=new GetSearchResults(currentQuery, GetSearchResults.Type.ACCOUNTS, false)
.setCallback(new SimpleCallback<>(this){
@Override
public void onSuccess(SearchResults result){
setEmptyText(R.string.no_search_results);
onDataLoaded(result.accounts.stream().map(a->new AccountViewModel(a, accountID)).collect(Collectors.toList()), false);
}
})
.exec(accountID);
}
@Override
protected void onUpdateToolbar(){
super.onUpdateToolbar();
searchViewHelper.install(getToolbar());
}
@Override
protected boolean wantsElevationOnScrollEffect(){
return false;
}
@Override
protected void onConfigureViewHolder(AccountViewHolder holder){
super.onConfigureViewHolder(holder);
holder.setOnClickListener(this::onItemClick);
holder.setStyle(AccountViewHolder.AccessoryType.NONE, false);
}
private void onItemClick(AccountViewHolder holder){
if(resultDelivered)
return;
resultDelivered=true;
Bundle res=new Bundle();
res.putParcelable("selectedAccount", Parcels.wrap(holder.getItem().account));
setResult(true, res);
Nav.finish(this, false);
}
private void onQueryChanged(String q){
currentQuery=q;
if(currentRequest!=null){
currentRequest.cancel();
currentRequest=null;
}
if(!TextUtils.isEmpty(currentQuery))
loadData();
}
@Override
public Uri getWebUri(Uri.Builder base) {
return null;
}
}

View File

@ -9,6 +9,7 @@ import org.joinmastodon.android.api.requests.HeaderPaginationRequest;
import org.joinmastodon.android.api.session.AccountSession;
import org.joinmastodon.android.model.Account;
import org.joinmastodon.android.model.HeaderPaginationList;
import org.joinmastodon.android.model.viewmodel.AccountViewModel;
import java.util.Collection;
import java.util.Collections;
@ -125,17 +126,17 @@ public abstract class PaginatedAccountListFragment<T> extends BaseAccountListFra
@Override
public void onSuccess(HeaderPaginationList<Account> result){
boolean justRefreshed = !doneWithHomeInstance && offset == 0;
Collection<AccountItem> d = justRefreshed ? List.of() : data;
Collection<AccountViewModel> d = justRefreshed ? List.of() : data;
if(result.nextPageUri!=null)
nextMaxID=result.nextPageUri.getQueryParameter("max_id");
else
nextMaxID=null;
if (getActivity() == null) return;
List<AccountItem> items = result.stream()
List<AccountViewModel> items = result.stream()
.filter(a -> d.size() > 1000 || d.stream()
.noneMatch(i -> i.account.url.equals(a.url)))
.map(AccountItem::new)
.map(a->new AccountViewModel(a, accountID))
.collect(Collectors.toList());
boolean hasMore = nextMaxID != null;

View File

@ -2,12 +2,12 @@ package org.joinmastodon.android.fragments.discover;
import android.net.Uri;
import android.os.Bundle;
import android.view.View;
import androidx.recyclerview.widget.RecyclerView;
import org.joinmastodon.android.api.requests.timelines.GetBubbleTimeline;
import org.joinmastodon.android.api.requests.timelines.GetPublicTimeline;
import org.joinmastodon.android.fragments.StatusListFragment;
import org.joinmastodon.android.model.Filter;
import org.joinmastodon.android.model.FilterContext;
import org.joinmastodon.android.model.Status;
import org.joinmastodon.android.ui.utils.DiscoverInfoBannerHelper;
import org.joinmastodon.android.utils.StatusFilterPredicate;
@ -16,20 +16,27 @@ import java.util.List;
import java.util.stream.Collectors;
import me.grishka.appkit.api.SimpleCallback;
import me.grishka.appkit.utils.MergeRecyclerAdapter;
public class BubbleTimelineFragment extends StatusListFragment {
private DiscoverInfoBannerHelper bannerHelper=new DiscoverInfoBannerHelper(DiscoverInfoBannerHelper.BannerType.BUBBLE_TIMELINE);
private DiscoverInfoBannerHelper bannerHelper;
private String maxID;
@Override
@Override
public void onCreate(Bundle savedInstanceState){
super.onCreate(savedInstanceState);
bannerHelper=new DiscoverInfoBannerHelper(DiscoverInfoBannerHelper.BannerType.BUBBLE_TIMELINE, accountID);
}
@Override
protected boolean wantsComposeButton() {
return true;
}
@Override
@Override
protected void doLoadData(int offset, int count){
currentRequest=new GetBubbleTimeline(refreshing ? null : maxID, count)
currentRequest=new GetBubbleTimeline(refreshing ? null : maxID, count, getLocalPrefs().timelineReplyVisibility)
.setCallback(new SimpleCallback<>(this){
@Override
public void onSuccess(List<Status> result){
@ -43,15 +50,17 @@ public class BubbleTimelineFragment extends StatusListFragment {
.exec(accountID);
}
@Override
public void onViewCreated(View view, Bundle savedInstanceState){
super.onViewCreated(view, savedInstanceState);
bannerHelper.maybeAddBanner(contentWrap);
}
@Override
protected RecyclerView.Adapter<?> getAdapter(){
MergeRecyclerAdapter adapter=new MergeRecyclerAdapter();
bannerHelper.maybeAddBanner(list, adapter);
adapter.addAdapter(super.getAdapter());
return adapter;
}
@Override
protected Filter.FilterContext getFilterContext() {
return Filter.FilterContext.PUBLIC;
@Override
protected FilterContext getFilterContext() {
return FilterContext.PUBLIC;
}
@Override

View File

@ -17,8 +17,8 @@ import org.joinmastodon.android.R;
import org.joinmastodon.android.api.requests.accounts.GetAccountRelationships;
import org.joinmastodon.android.api.requests.accounts.GetFollowSuggestions;
import org.joinmastodon.android.fragments.IsOnTop;
import org.joinmastodon.android.fragments.MastodonRecyclerFragment;
import org.joinmastodon.android.fragments.ProfileFragment;
import org.joinmastodon.android.fragments.RecyclerFragment;
import org.joinmastodon.android.fragments.ScrollableToTop;
import org.joinmastodon.android.model.Account;
import org.joinmastodon.android.model.FollowSuggestion;
@ -51,7 +51,7 @@ import me.grishka.appkit.utils.BindableViewHolder;
import me.grishka.appkit.utils.V;
import me.grishka.appkit.views.UsableRecyclerView;
public class DiscoverAccountsFragment extends RecyclerFragment<DiscoverAccountsFragment.AccountWrapper> implements ScrollableToTop, IsOnTop, ProvidesAssistContent.ProvidesWebUri {
public class DiscoverAccountsFragment extends MastodonRecyclerFragment<DiscoverAccountsFragment.AccountWrapper> implements ScrollableToTop, IsOnTop, ProvidesAssistContent.ProvidesWebUri {
private String accountID;
private Map<String, Relationship> relationships=Collections.emptyMap();
private GetAccountRelationships relationshipsRequest;
@ -242,7 +242,7 @@ public class DiscoverAccountsFragment extends RecyclerFragment<DiscoverAccountsF
postsCount.setText(UiUtils.abbreviateNumber(item.account.statusesCount));
followersLabel.setText(getResources().getQuantityString(R.plurals.followers, (int)Math.min(999, item.account.followersCount)));
followingLabel.setText(getResources().getQuantityString(R.plurals.following, (int)Math.min(999, item.account.followingCount)));
postsLabel.setText(getResources().getQuantityString(R.plurals.posts, (int)Math.min(999, item.account.statusesCount)));
postsLabel.setText(getResources().getQuantityString(R.plurals.x_posts, (int)(item.account.statusesCount%1000), item.account.statusesCount));
followersCount.setVisibility(item.account.followersCount < 0 ? View.GONE : View.VISIBLE);
followersLabel.setVisibility(item.account.followersCount < 0 ? View.GONE : View.VISIBLE);
followingCount.setVisibility(item.account.followingCount < 0 ? View.GONE : View.VISIBLE);
@ -252,7 +252,7 @@ public class DiscoverAccountsFragment extends RecyclerFragment<DiscoverAccountsF
actionWrap.setVisibility(View.GONE);
}else{
actionWrap.setVisibility(View.VISIBLE);
UiUtils.setRelationshipToActionButton(relationship, actionButton);
UiUtils.setRelationshipToActionButtonM3(relationship, actionButton);
}
}

View File

@ -1,64 +1,58 @@
package org.joinmastodon.android.fragments.discover;
import android.app.Fragment;
import android.app.assist.AssistContent;
import android.os.Build;
import android.os.Bundle;
import android.text.Editable;
import android.text.TextWatcher;
import android.view.KeyEvent;
import android.text.TextUtils;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.view.inputmethod.InputMethodManager;
import android.widget.EditText;
import android.widget.FrameLayout;
import android.widget.ImageButton;
import android.widget.LinearLayout;
import android.widget.ProgressBar;
import android.widget.TextView;
import org.joinmastodon.android.GlobalUserPreferences;
import org.joinmastodon.android.R;
import org.joinmastodon.android.fragments.HomeFragment;
import org.joinmastodon.android.fragments.IsOnTop;
import org.joinmastodon.android.fragments.ScrollableToTop;
import org.joinmastodon.android.model.SearchResult;
import org.joinmastodon.android.ui.OutlineProviders;
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.ProvidesAssistContent;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.recyclerview.widget.RecyclerView;
import androidx.viewpager2.widget.ViewPager2;
import me.grishka.appkit.Nav;
import me.grishka.appkit.fragments.AppKitFragment;
import me.grishka.appkit.fragments.BaseRecyclerFragment;
import me.grishka.appkit.fragments.OnBackPressedListener;
import me.grishka.appkit.utils.V;
public class DiscoverFragment extends AppKitFragment implements ScrollableToTop, OnBackPressedListener, IsOnTop, ProvidesAssistContent {
public class DiscoverFragment extends AppKitFragment implements ScrollableToTop, OnBackPressedListener, IsOnTop {
private static final int QUERY_RESULT=937;
private TabLayout tabLayout;
private ViewPager2 pager;
private FrameLayout[] tabViews;
private TabLayoutMediator tabLayoutMediator;
private EditText searchEdit;
private boolean searchActive;
private FrameLayout searchView;
private ImageButton searchBack, searchClear;
private ProgressBar searchProgress;
private ImageButton searchBack;
private TextView searchText;
private View tabsDivider;
private DiscoverPostsFragment postsFragment;
private DiscoverHashtagsFragment hashtagsFragment;
private TrendingHashtagsFragment hashtagsFragment;
private DiscoverNewsFragment newsFragment;
private DiscoverAccountsFragment accountsFragment;
private SearchFragment searchFragment;
private String accountID;
private Runnable searchDebouncer=this::onSearchChangedDebounced;
private String currentQuery;
@Override
public void onCreate(Bundle savedInstanceState){
@ -92,12 +86,10 @@ public class DiscoverFragment extends AppKitFragment implements ScrollableToTop,
tabViews[i]=tabView;
}
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.setTabTextSize(V.dp(14));
UiUtils.reduceSwipeSensitivity(pager);
pager.setOffscreenPageLimit(4);
pager.setUserInputEnabled(!GlobalUserPreferences.disableSwipe);
pager.setAdapter(new DiscoverPagerAdapter());
pager.registerOnPageChangeCallback(new ViewPager2.OnPageChangeCallback(){
@Override
@ -120,7 +112,7 @@ public class DiscoverFragment extends AppKitFragment implements ScrollableToTop,
postsFragment=new DiscoverPostsFragment();
postsFragment.setArguments(args);
hashtagsFragment=new DiscoverHashtagsFragment();
hashtagsFragment=new TrendingHashtagsFragment();
hashtagsFragment.setArguments(args);
newsFragment=new DiscoverNewsFragment();
@ -129,10 +121,9 @@ public class DiscoverFragment extends AppKitFragment implements ScrollableToTop,
accountsFragment=new DiscoverAccountsFragment();
accountsFragment.setArguments(args);
getChildFragmentManager().beginTransaction()
.add(R.id.discover_posts, postsFragment)
.add(R.id.discover_hashtags, hashtagsFragment)
.add(R.id.discover_posts, postsFragment)
.add(R.id.discover_news, newsFragment)
.add(R.id.discover_users, accountsFragment)
.commit();
@ -148,7 +139,6 @@ public class DiscoverFragment extends AppKitFragment implements ScrollableToTop,
case 3 -> R.string.for_you;
default -> throw new IllegalStateException("Unexpected value: "+position);
});
tab.view.textView.setAllCaps(true);
}
});
tabLayoutMediator.attach();
@ -165,62 +155,52 @@ public class DiscoverFragment extends AppKitFragment implements ScrollableToTop,
}
});
searchEdit=view.findViewById(R.id.search_edit);
searchEdit.setOnFocusChangeListener(this::onSearchEditFocusChanged);
searchEdit.setOnEditorActionListener(this::onSearchEnterPressed);
searchEdit.addTextChangedListener(new TextWatcher(){
@Override
public void beforeTextChanged(CharSequence s, int start, int count, int after){
if(s.length()==0){
V.setVisibilityAnimated(searchClear, View.VISIBLE);
}
}
@Override
public void onTextChanged(CharSequence s, int start, int before, int count){
searchEdit.removeCallbacks(searchDebouncer);
searchEdit.postDelayed(searchDebouncer, 300);
}
@Override
public void afterTextChanged(Editable s){
if(s.length()==0){
V.setVisibilityAnimated(searchClear, View.INVISIBLE);
}
}
});
searchView=view.findViewById(R.id.search_fragment);
if(searchFragment==null){
searchFragment=new SearchFragment();
Bundle args=new Bundle();
args.putString("account", accountID);
searchFragment.setArguments(args);
searchFragment.setProgressVisibilityListener(this::onSearchProgressVisibilityChanged);
getChildFragmentManager().beginTransaction().add(R.id.search_fragment, searchFragment).commit();
}
searchBack=view.findViewById(R.id.search_back);
searchClear=view.findViewById(R.id.search_clear);
searchProgress=view.findViewById(R.id.search_progress);
searchBack.setEnabled(searchActive);
searchText=view.findViewById(R.id.search_text);
searchBack.setImportantForAccessibility(searchActive ? View.IMPORTANT_FOR_ACCESSIBILITY_YES : View.IMPORTANT_FOR_ACCESSIBILITY_NO);
searchBack.setOnClickListener(v->exitSearch());
searchBack.setOnClickListener(v->{
if(searchActive) exitSearch(); else openSearch();
});
if(searchActive){
searchBack.setImageResource(R.drawable.ic_fluent_arrow_left_24_regular);
pager.setVisibility(View.GONE);
tabLayout.setVisibility(View.GONE);
searchView.setVisibility(View.VISIBLE);
}
searchClear.setOnClickListener(v->{
searchEdit.setText("");
searchEdit.removeCallbacks(searchDebouncer);
onSearchChangedDebounced();
});
View searchWrap=view.findViewById(R.id.search_wrap);
searchWrap.setOutlineProvider(OutlineProviders.roundedRect(28));
searchWrap.setClipToOutline(true);
searchText.setOnClickListener(v->openSearch());
tabsDivider=view.findViewById(R.id.tabs_divider);
return view;
}
@Override
public boolean isOnTop() {
return searchActive ? searchFragment.isOnTop()
: ((IsOnTop)getFragmentForPage(pager.getCurrentItem())).isOnTop();
}
public void openSearch() {
Bundle args=new Bundle();
args.putString("account", accountID);
if(!TextUtils.isEmpty(currentQuery)){
args.putString("query", currentQuery);
}
Nav.goForResult(getActivity(), SearchQueryFragment.class, args, QUERY_RESULT, DiscoverFragment.this);
}
@Override
public void scrollToTop(){
if(!searchActive){
@ -230,30 +210,13 @@ public class DiscoverFragment extends AppKitFragment implements ScrollableToTop,
}
}
@Override
public boolean isOnTop() {
return searchActive ? searchFragment.isOnTop()
: ((IsOnTop)getFragmentForPage(pager.getCurrentItem())).isOnTop();
}
public void onSelect() {
if (isOnTop()) selectSearch();
else scrollToTop();
}
public void selectSearch() {
searchEdit.requestFocus();
onSearchEditFocusChanged(searchEdit, true);
getActivity().getSystemService(InputMethodManager.class).showSoftInput(searchEdit, 0);
}
public void loadData(){
if(hashtagsFragment!=null && !hashtagsFragment.loaded && !hashtagsFragment.dataLoading)
hashtagsFragment.loadData();
if(postsFragment!=null && !postsFragment.loaded && !postsFragment.dataLoading)
postsFragment.loadData();
}
private void onSearchEditFocusChanged(View v, boolean hasFocus){
if(!searchActive && hasFocus){
private void enterSearch(){
if(!searchActive){
searchActive=true;
pager.setVisibility(View.GONE);
tabLayout.setVisibility(View.GONE);
@ -261,28 +224,23 @@ public class DiscoverFragment extends AppKitFragment implements ScrollableToTop,
searchBack.setImageResource(R.drawable.ic_fluent_arrow_left_24_regular);
searchBack.setEnabled(true);
searchBack.setImportantForAccessibility(View.IMPORTANT_FOR_ACCESSIBILITY_YES);
tabsDivider.setVisibility(View.GONE);
}
}
private void exitSearch(){
if(!searchActive)
return;
searchActive=false;
pager.setVisibility(View.VISIBLE);
tabLayout.setVisibility(View.VISIBLE);
searchView.setVisibility(View.GONE);
searchEdit.clearFocus();
searchEdit.setText("");
searchText.setText(R.string.search_mastodon);
searchBack.setImageResource(R.drawable.ic_fluent_search_24_regular);
searchBack.setEnabled(false);
searchBack.setImportantForAccessibility(View.IMPORTANT_FOR_ACCESSIBILITY_NO);
getActivity().getSystemService(InputMethodManager.class).hideSoftInputFromWindow(searchEdit.getWindowToken(), 0);
if (getArguments().getBoolean("disableDiscover"))
((HomeFragment) getParentFragment()).onBackPressed();
}
@Override
protected void onHidden(){
super.onHidden();
getActivity().getSystemService(InputMethodManager.class).hideSoftInputFromWindow(searchEdit.getWindowToken(), 0);
tabsDivider.setVisibility(View.VISIBLE);
currentQuery=null;
}
private Fragment getFragmentForPage(int page){
@ -304,30 +262,20 @@ public class DiscoverFragment extends AppKitFragment implements ScrollableToTop,
return false;
}
private void onSearchChangedDebounced(){
searchFragment.setQuery(searchEdit.getText().toString());
}
private boolean onSearchEnterPressed(TextView v, int actionId, KeyEvent event){
if(event!=null && event.getAction()!=KeyEvent.ACTION_DOWN)
return true;
searchEdit.removeCallbacks(searchDebouncer);
onSearchChangedDebounced();
getActivity().getSystemService(InputMethodManager.class).hideSoftInputFromWindow(searchEdit.getWindowToken(), 0);
return true;
}
private void onSearchProgressVisibilityChanged(boolean visible){
V.setVisibilityAnimated(searchProgress, visible ? View.VISIBLE : View.INVISIBLE);
if(searchEdit.length()>0)
V.setVisibilityAnimated(searchClear, visible ? View.INVISIBLE : View.VISIBLE);
}
@Override
public void onProvideAssistContent(AssistContent assistContent) {
callFragmentToProvideAssistContent(searchActive
? searchFragment
: getFragmentForPage(pager.getCurrentItem()), assistContent);
public void onFragmentResult(int reqCode, boolean success, Bundle result){
if(reqCode==QUERY_RESULT && success){
enterSearch();
currentQuery=result.getString("query");
SearchResult.Type type;
if(result.containsKey("filter")){
type=SearchResult.Type.values()[result.getInt("filter")];
}else{
type=null;
}
searchFragment.setQuery(currentQuery, type);
searchText.setText(currentQuery);
}
}
private class DiscoverPagerAdapter extends RecyclerView.Adapter<SimpleViewHolder>{

View File

@ -1,7 +1,7 @@
package org.joinmastodon.android.fragments.discover;
import android.graphics.Rect;
import android.graphics.drawable.Drawable;
import android.net.Uri;
import android.os.Bundle;
import android.text.TextUtils;
import android.view.View;
@ -11,36 +11,45 @@ import android.widget.TextView;
import org.joinmastodon.android.R;
import org.joinmastodon.android.api.requests.trends.GetTrendingLinks;
import org.joinmastodon.android.fragments.IsOnTop;
import org.joinmastodon.android.fragments.RecyclerFragment;
import org.joinmastodon.android.fragments.ScrollableToTop;
import org.joinmastodon.android.model.Card;
import org.joinmastodon.android.model.viewmodel.CardViewModel;
import org.joinmastodon.android.ui.DividerItemDecoration;
import org.joinmastodon.android.ui.OutlineProviders;
import org.joinmastodon.android.ui.drawables.BlurhashCrossfadeDrawable;
import org.joinmastodon.android.ui.utils.DiscoverInfoBannerHelper;
import org.joinmastodon.android.ui.utils.UiUtils;
import org.joinmastodon.android.utils.ProvidesAssistContent;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.stream.Collectors;
import androidx.annotation.NonNull;
import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
import me.grishka.appkit.api.SimpleCallback;
import me.grishka.appkit.fragments.BaseRecyclerFragment;
import me.grishka.appkit.imageloader.ImageLoaderRecyclerAdapter;
import me.grishka.appkit.imageloader.ImageLoaderViewHolder;
import me.grishka.appkit.imageloader.ListImageLoaderAdapter;
import me.grishka.appkit.imageloader.ListImageLoaderWrapper;
import me.grishka.appkit.imageloader.RecyclerViewDelegate;
import me.grishka.appkit.imageloader.requests.ImageLoaderRequest;
import me.grishka.appkit.imageloader.requests.UrlImageLoaderRequest;
import me.grishka.appkit.utils.BindableViewHolder;
import me.grishka.appkit.utils.MergeRecyclerAdapter;
import me.grishka.appkit.utils.SingleViewRecyclerAdapter;
import me.grishka.appkit.utils.V;
import me.grishka.appkit.views.UsableRecyclerView;
public class DiscoverNewsFragment extends RecyclerFragment<Card> implements ScrollableToTop, IsOnTop, ProvidesAssistContent.ProvidesWebUri {
public class DiscoverNewsFragment extends BaseRecyclerFragment<CardViewModel> implements ScrollableToTop{
private String accountID;
private List<ImageLoaderRequest> imageRequests=Collections.emptyList();
private DiscoverInfoBannerHelper bannerHelper=new DiscoverInfoBannerHelper(DiscoverInfoBannerHelper.BannerType.TRENDING_LINKS);
private DiscoverInfoBannerHelper bannerHelper;
private MergeRecyclerAdapter mergeAdapter;
private UsableRecyclerView cardsList;
private ArrayList<CardViewModel> top3=new ArrayList<>();
private CardLinksAdapter cardsAdapter;
public DiscoverNewsFragment(){
super(10);
@ -50,6 +59,7 @@ public class DiscoverNewsFragment extends RecyclerFragment<Card> implements Scro
public void onCreate(Bundle savedInstanceState){
super.onCreate(savedInstanceState);
accountID=getArguments().getString("account");
bannerHelper=new DiscoverInfoBannerHelper(DiscoverInfoBannerHelper.BannerType.TRENDING_LINKS, accountID);
}
@Override
@ -58,11 +68,14 @@ public class DiscoverNewsFragment extends RecyclerFragment<Card> implements Scro
.setCallback(new SimpleCallback<>(this){
@Override
public void onSuccess(List<Card> result){
imageRequests=result.stream()
.map(card->TextUtils.isEmpty(card.image) ? null : new UrlImageLoaderRequest(card.image, V.dp(150), V.dp(150)))
.collect(Collectors.toList());
if (getActivity() == null) return;
onDataLoaded(result, false);
top3.clear();
top3.addAll(result.subList(0, Math.min(3, result.size())).stream().map(card->new CardViewModel(card, 280, 140)).collect(Collectors.toList()));
cardsAdapter.notifyDataSetChanged();
onDataLoaded(result.subList(top3.size(), result.size()).stream()
.map(card->new CardViewModel(card, 56, 56))
.collect(Collectors.toList()), false);
bannerHelper.onBannerBecameVisible();
}
})
.exec(accountID);
@ -70,14 +83,27 @@ public class DiscoverNewsFragment extends RecyclerFragment<Card> implements Scro
@Override
protected RecyclerView.Adapter getAdapter(){
return new LinksAdapter();
}
cardsList=new UsableRecyclerView(getActivity());
cardsList.setLayoutManager(new LinearLayoutManager(getActivity(), LinearLayoutManager.HORIZONTAL, false));
ListImageLoaderWrapper cardsImageLoader=new ListImageLoaderWrapper(getActivity(), cardsList, new RecyclerViewDelegate(cardsList), this);
cardsList.setAdapter(cardsAdapter=new CardLinksAdapter(cardsImageLoader, top3));
cardsList.setLayoutParams(new ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, V.dp(256)));
cardsList.setPadding(V.dp(16), V.dp(8), 0, 0);
cardsList.setClipToPadding(false);
cardsList.addItemDecoration(new RecyclerView.ItemDecoration(){
@Override
public void getItemOffsets(@NonNull Rect outRect, @NonNull View view, @NonNull RecyclerView parent, @NonNull RecyclerView.State state){
outRect.right=V.dp(16);
}
});
cardsList.setSelector(R.drawable.bg_rect_12dp_ripple);
cardsList.setDrawSelectorOnTop(true);
@Override
public void onViewCreated(View view, Bundle savedInstanceState){
super.onViewCreated(view, savedInstanceState);
list.addItemDecoration(new DividerItemDecoration(getActivity(), R.attr.colorPollVoted, 1, 0, 0));
bannerHelper.maybeAddBanner(contentWrap);
mergeAdapter=new MergeRecyclerAdapter();
bannerHelper.maybeAddBanner(list, mergeAdapter);
mergeAdapter.addAdapter(new SingleViewRecyclerAdapter(cardsList));
mergeAdapter.addAdapter(new LinksAdapter(imgLoader, data));
return mergeAdapter;
}
@Override
@ -85,29 +111,17 @@ public class DiscoverNewsFragment extends RecyclerFragment<Card> implements Scro
smoothScrollRecyclerViewToTop(list);
}
@Override
public boolean isOnTop() {
return isRecyclerViewOnTop(list);
}
private class LinksAdapter extends UsableRecyclerView.Adapter<BaseLinkViewHolder> implements ImageLoaderRecyclerAdapter{
private final List<CardViewModel> data;
@Override
public String getAccountID() {
return accountID;
}
@Override
public Uri getWebUri(Uri.Builder base) {
return isInstanceAkkoma() ? null : base.path("/explore/links").build();
}
private class LinksAdapter extends UsableRecyclerView.Adapter<LinkViewHolder> implements ImageLoaderRecyclerAdapter{
public LinksAdapter(){
public LinksAdapter(ListImageLoaderWrapper imgLoader, List<CardViewModel> data){
super(imgLoader);
this.data=data;
}
@NonNull
@Override
public LinkViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType){
public BaseLinkViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType){
return new LinkViewHolder();
}
@ -117,46 +131,51 @@ public class DiscoverNewsFragment extends RecyclerFragment<Card> implements Scro
}
@Override
public void onBindViewHolder(LinkViewHolder holder, int position){
holder.bind(data.get(position));
public void onBindViewHolder(BaseLinkViewHolder holder, int position){
holder.bind(data.get(position).card);
super.onBindViewHolder(holder, position);
}
@Override
public int getImageCountForItem(int position){
return imageRequests.get(position)==null ? 0 : 1;
return data.get(position).imageRequest==null ? 0 : 1;
}
@Override
public ImageLoaderRequest getImageRequest(int position, int image){
return imageRequests.get(position);
return data.get(position).imageRequest;
}
}
private class LinkViewHolder extends BindableViewHolder<Card> implements UsableRecyclerView.Clickable, ImageLoaderViewHolder{
private final TextView name, title, subtitle;
private final ImageView photo;
private class CardLinksAdapter extends LinksAdapter{
public CardLinksAdapter(ListImageLoaderWrapper imgLoader, List<CardViewModel> data){
super(imgLoader, data);
}
@NonNull
@Override
public BaseLinkViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType){
return new LinkCardViewHolder();
}
}
private class BaseLinkViewHolder extends BindableViewHolder<Card> implements UsableRecyclerView.Clickable, ImageLoaderViewHolder{
protected final TextView name, title;
protected final ImageView photo;
private BlurhashCrossfadeDrawable crossfadeDrawable=new BlurhashCrossfadeDrawable();
private boolean didClear;
public LinkViewHolder(){
super(getActivity(), R.layout.item_trending_link, list);
public BaseLinkViewHolder(int layout){
super(getActivity(), layout, list);
name=findViewById(R.id.name);
title=findViewById(R.id.title);
subtitle=findViewById(R.id.subtitle);
photo=findViewById(R.id.photo);
photo.setOutlineProvider(OutlineProviders.roundedRect(2));
photo.setClipToOutline(true);
}
@Override
public void onBind(Card item){
name.setText(item.providerName);
title.setText(item.title);
int num=item.history.get(0).uses;
if(item.history.size()>1)
num+=item.history.get(1).uses;
subtitle.setText(getResources().getQuantityString(R.plurals.discussed_x_times, num, num));
crossfadeDrawable.setSize(item.width, item.height);
crossfadeDrawable.setBlurhashDrawable(item.blurhashPlaceholder);
crossfadeDrawable.setCrossfadeAlpha(0f);
@ -183,4 +202,20 @@ public class DiscoverNewsFragment extends RecyclerFragment<Card> implements Scro
UiUtils.launchWebBrowser(getActivity(), item.url);
}
}
private class LinkViewHolder extends BaseLinkViewHolder{
public LinkViewHolder(){
super(R.layout.item_trending_link);
photo.setOutlineProvider(OutlineProviders.roundedRect(12));
photo.setClipToOutline(true);
}
}
private class LinkCardViewHolder extends BaseLinkViewHolder{
public LinkCardViewHolder(){
super(R.layout.item_trending_link_card);
itemView.setOutlineProvider(OutlineProviders.roundedRect(12));
itemView.setClipToOutline(true);
}
}
}

View File

@ -2,22 +2,27 @@ package org.joinmastodon.android.fragments.discover;
import android.net.Uri;
import android.os.Bundle;
import android.view.View;
import org.joinmastodon.android.api.requests.trends.GetTrendingStatuses;
import org.joinmastodon.android.fragments.StatusListFragment;
import org.joinmastodon.android.model.Filter;
import org.joinmastodon.android.model.FilterContext;
import org.joinmastodon.android.model.Status;
import org.joinmastodon.android.ui.utils.DiscoverInfoBannerHelper;
import org.joinmastodon.android.utils.StatusFilterPredicate;
import java.util.List;
import java.util.stream.Collectors;
import androidx.recyclerview.widget.RecyclerView;
import me.grishka.appkit.api.SimpleCallback;
import me.grishka.appkit.utils.MergeRecyclerAdapter;
public class DiscoverPostsFragment extends StatusListFragment {
private DiscoverInfoBannerHelper bannerHelper=new DiscoverInfoBannerHelper(DiscoverInfoBannerHelper.BannerType.TRENDING_POSTS);
public class DiscoverPostsFragment extends StatusListFragment{
private DiscoverInfoBannerHelper bannerHelper;
@Override
public void onCreate(Bundle savedInstanceState){
super.onCreate(savedInstanceState);
bannerHelper=new DiscoverInfoBannerHelper(DiscoverInfoBannerHelper.BannerType.TRENDING_POSTS, accountID);
}
@Override
protected void doLoadData(int offset, int count){
@ -25,26 +30,27 @@ public class DiscoverPostsFragment extends StatusListFragment {
.setCallback(new SimpleCallback<>(this){
@Override
public void onSuccess(List<Status> result){
if (getActivity() == null) return;
result=result.stream().filter(new StatusFilterPredicate(accountID, getFilterContext())).collect(Collectors.toList());
onDataLoaded(result, !result.isEmpty());
bannerHelper.onBannerBecameVisible();
}
}).exec(accountID);
}
@Override
public void onViewCreated(View view, Bundle savedInstanceState){
super.onViewCreated(view, savedInstanceState);
bannerHelper.maybeAddBanner(contentWrap);
protected RecyclerView.Adapter<?> getAdapter(){
MergeRecyclerAdapter adapter=new MergeRecyclerAdapter();
bannerHelper.maybeAddBanner(list, adapter);
adapter.addAdapter(super.getAdapter());
return adapter;
}
@Override
protected Filter.FilterContext getFilterContext() {
return Filter.FilterContext.PUBLIC;
protected FilterContext getFilterContext() {
return FilterContext.PUBLIC;
}
@Override
public Uri getWebUri(Uri.Builder base) {
public Uri getWebUri(Uri.Builder base){
return isInstanceAkkoma() ? null : base.path("/explore/posts").build();
}
}

View File

@ -2,55 +2,59 @@ package org.joinmastodon.android.fragments.discover;
import android.net.Uri;
import android.os.Bundle;
import android.view.View;
import org.joinmastodon.android.R;
import org.joinmastodon.android.api.requests.timelines.GetPublicTimeline;
import org.joinmastodon.android.api.session.AccountSessionManager;
import org.joinmastodon.android.fragments.StatusListFragment;
import org.joinmastodon.android.model.Filter;
import org.joinmastodon.android.model.FilterContext;
import org.joinmastodon.android.model.Status;
import org.joinmastodon.android.ui.utils.DiscoverInfoBannerHelper;
import org.joinmastodon.android.utils.StatusFilterPredicate;
import java.util.List;
import java.util.stream.Collectors;
import androidx.recyclerview.widget.RecyclerView;
import me.grishka.appkit.api.SimpleCallback;
import me.grishka.appkit.utils.MergeRecyclerAdapter;
public class FederatedTimelineFragment extends StatusListFragment{
private DiscoverInfoBannerHelper bannerHelper;
public class FederatedTimelineFragment extends StatusListFragment {
private DiscoverInfoBannerHelper bannerHelper=new DiscoverInfoBannerHelper(DiscoverInfoBannerHelper.BannerType.FEDERATED_TIMELINE);
private String maxID;
@Override
protected boolean wantsComposeButton() {
return true;
public void onCreate(Bundle savedInstanceState){
super.onCreate(savedInstanceState);
bannerHelper=new DiscoverInfoBannerHelper(DiscoverInfoBannerHelper.BannerType.FEDERATED_TIMELINE, accountID);
}
@Override
protected void doLoadData(int offset, int count){
currentRequest=new GetPublicTimeline(false, false, refreshing ? null : maxID, count)
currentRequest=new GetPublicTimeline(false, false, refreshing ? null : maxID, count, getLocalPrefs().timelineReplyVisibility)
.setCallback(new SimpleCallback<>(this){
@Override
public void onSuccess(List<Status> result){
if(!result.isEmpty())
maxID=result.get(result.size()-1).id;
if (getActivity() == null) return;
result=result.stream().filter(new StatusFilterPredicate(accountID, getFilterContext())).collect(Collectors.toList());
onDataLoaded(result, !result.isEmpty());
boolean empty=result.isEmpty();
AccountSessionManager.get(accountID).filterStatuses(result, FilterContext.PUBLIC);
onDataLoaded(result, !empty);
bannerHelper.onBannerBecameVisible();
}
})
.exec(accountID);
}
@Override
public void onViewCreated(View view, Bundle savedInstanceState){
super.onViewCreated(view, savedInstanceState);
bannerHelper.maybeAddBanner(contentWrap);
protected RecyclerView.Adapter<?> getAdapter(){
MergeRecyclerAdapter adapter=new MergeRecyclerAdapter();
bannerHelper.maybeAddBanner(list, adapter);
adapter.addAdapter(super.getAdapter());
return adapter;
}
@Override
protected Filter.FilterContext getFilterContext() {
return Filter.FilterContext.PUBLIC;
protected FilterContext getFilterContext() {
return FilterContext.PUBLIC;
}
@Override

View File

@ -7,54 +7,59 @@ import android.view.View;
import org.joinmastodon.android.api.requests.timelines.GetPublicTimeline;
import org.joinmastodon.android.api.session.AccountSessionManager;
import org.joinmastodon.android.fragments.StatusListFragment;
import org.joinmastodon.android.model.Filter;
import org.joinmastodon.android.model.FilterContext;
import org.joinmastodon.android.model.Status;
import org.joinmastodon.android.ui.utils.DiscoverInfoBannerHelper;
import org.joinmastodon.android.utils.StatusFilterPredicate;
import java.util.List;
import java.util.stream.Collectors;
import androidx.recyclerview.widget.RecyclerView;
import me.grishka.appkit.api.SimpleCallback;
import me.grishka.appkit.utils.MergeRecyclerAdapter;
public class LocalTimelineFragment extends StatusListFragment{
private DiscoverInfoBannerHelper bannerHelper;
public class LocalTimelineFragment extends StatusListFragment {
private DiscoverInfoBannerHelper bannerHelper=new DiscoverInfoBannerHelper(DiscoverInfoBannerHelper.BannerType.LOCAL_TIMELINE);
private String maxID;
@Override
protected boolean wantsComposeButton() {
return true;
public void onCreate(Bundle savedInstanceState){
super.onCreate(savedInstanceState);
bannerHelper=new DiscoverInfoBannerHelper(DiscoverInfoBannerHelper.BannerType.LOCAL_TIMELINE, accountID);
}
@Override
protected void doLoadData(int offset, int count){
currentRequest=new GetPublicTimeline(true, false, refreshing ? null : maxID, count)
currentRequest=new GetPublicTimeline(true, false, refreshing ? null : maxID, count, getLocalPrefs().timelineReplyVisibility)
.setCallback(new SimpleCallback<>(this){
@Override
public void onSuccess(List<Status> result){
if(!result.isEmpty())
maxID=result.get(result.size()-1).id;
if (getActivity() == null) return;
result=result.stream().filter(new StatusFilterPredicate(accountID, getFilterContext())).collect(Collectors.toList());
onDataLoaded(result, !result.isEmpty());
boolean empty=result.isEmpty();
AccountSessionManager.get(accountID).filterStatuses(result, FilterContext.PUBLIC);
onDataLoaded(result, !empty);
bannerHelper.onBannerBecameVisible();
}
})
.exec(accountID);
}
@Override
public void onViewCreated(View view, Bundle savedInstanceState){
super.onViewCreated(view, savedInstanceState);
bannerHelper.maybeAddBanner(contentWrap);
protected RecyclerView.Adapter<?> getAdapter(){
MergeRecyclerAdapter adapter=new MergeRecyclerAdapter();
bannerHelper.maybeAddBanner(list, adapter);
adapter.addAdapter(super.getAdapter());
return adapter;
}
@Override
protected Filter.FilterContext getFilterContext() {
return Filter.FilterContext.PUBLIC;
protected FilterContext getFilterContext() {
return FilterContext.PUBLIC;
}
@Override
public Uri getWebUri(Uri.Builder base) {
public Uri getWebUri(Uri.Builder base){
return base.path(isInstanceAkkoma() ? "/main/public" : "/public/local").build();
}
}

View File

@ -4,7 +4,6 @@ import android.app.Activity;
import android.net.Uri;
import android.os.Build;
import android.os.Bundle;
import android.text.TextUtils;
import android.view.View;
import android.view.inputmethod.InputMethodManager;
@ -15,7 +14,7 @@ import org.joinmastodon.android.fragments.BaseStatusListFragment;
import org.joinmastodon.android.fragments.ProfileFragment;
import org.joinmastodon.android.fragments.ThreadFragment;
import org.joinmastodon.android.model.Account;
import org.joinmastodon.android.model.Filter;
import org.joinmastodon.android.model.FilterContext;
import org.joinmastodon.android.model.Hashtag;
import org.joinmastodon.android.model.SearchResult;
import org.joinmastodon.android.model.SearchResults;
@ -24,7 +23,6 @@ import org.joinmastodon.android.ui.displayitems.AccountStatusDisplayItem;
import org.joinmastodon.android.ui.displayitems.HashtagStatusDisplayItem;
import org.joinmastodon.android.ui.displayitems.StatusDisplayItem;
import org.joinmastodon.android.ui.tabs.TabLayout;
import org.joinmastodon.android.ui.utils.HideableSingleViewRecyclerAdapter;
import org.joinmastodon.android.ui.utils.UiUtils;
import org.parceler.Parcels;
@ -35,24 +33,18 @@ import java.util.List;
import java.util.Objects;
import java.util.stream.Collectors;
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.utils.MergeRecyclerAdapter;
import me.grishka.appkit.utils.V;
public class SearchFragment extends BaseStatusListFragment<SearchResult> {
public class SearchFragment extends BaseStatusListFragment<SearchResult>{
private String currentQuery;
private List<StatusDisplayItem> prevDisplayItems;
private EnumSet<SearchResult.Type> currentFilter=EnumSet.allOf(SearchResult.Type.class);
private List<SearchResult> unfilteredResults=Collections.emptyList();
private HideableSingleViewRecyclerAdapter headerAdapter;
private ProgressVisibilityListener progressVisibilityListener;
private InputMethodManager imm;
private TabLayout tabLayout;
public SearchFragment(){
setLayout(R.layout.fragment_search);
}
@ -62,8 +54,8 @@ public class SearchFragment extends BaseStatusListFragment<SearchResult> {
super.onCreate(savedInstanceState);
if(Build.VERSION.SDK_INT>=Build.VERSION_CODES.N)
setRetainInstance(true);
setEmptyText(R.string.no_search_results);
loadData();
resetEmptyText();
}
@Override
@ -72,16 +64,12 @@ public class SearchFragment extends BaseStatusListFragment<SearchResult> {
imm=activity.getSystemService(InputMethodManager.class);
}
private void resetEmptyText() {
setEmptyText(R.string.sk_recent_searches_placeholder);
}
@Override
protected List<StatusDisplayItem> buildDisplayItems(SearchResult s){
return switch(s.type){
case ACCOUNT -> Collections.singletonList(new AccountStatusDisplayItem(s.id, this, s.account));
case HASHTAG -> Collections.singletonList(new HashtagStatusDisplayItem(s.id, this, s.hashtag));
case STATUS -> StatusDisplayItem.buildItems(this, s.status, accountID, s, knownAccounts, false, true, null, Filter.FilterContext.PUBLIC);
case STATUS -> StatusDisplayItem.buildItems(this, s.status, accountID, s, knownAccounts, FilterContext.PUBLIC, 0);
};
}
@ -125,57 +113,52 @@ public class SearchFragment extends BaseStatusListFragment<SearchResult> {
@Override
protected void doLoadData(int offset, int count){
if (getActivity() == null) return;
resetEmptyText();
if(isInRecentMode()){
AccountSessionManager.getInstance().getAccount(accountID).getCacheController().getRecentSearches(sr->{
if(getActivity()==null)
return;
unfilteredResults=sr;
prevDisplayItems=new ArrayList<>(displayItems);
onDataLoaded(sr, false);
});
GetSearchResults.Type type;
if(currentFilter.size()==1){
type=switch(currentFilter.iterator().next()){
case ACCOUNT -> GetSearchResults.Type.ACCOUNTS;
case HASHTAG -> GetSearchResults.Type.HASHTAGS;
case STATUS -> GetSearchResults.Type.STATUSES;
};
}else{
setEmptyText(R.string.sk_searching);
progressVisibilityListener.onProgressVisibilityChanged(true);
currentRequest=new GetSearchResults(currentQuery, null, true)
.setCallback(new Callback<>(){
@Override
public void onSuccess(SearchResults result){
setEmptyText(R.string.sk_no_results);
ArrayList<SearchResult> results=new ArrayList<>();
if(result.accounts!=null){
for(Account acc:result.accounts)
results.add(new SearchResult(acc));
}
if(result.hashtags!=null){
for(Hashtag tag:result.hashtags)
results.add(new SearchResult(tag));
}
if(result.statuses!=null){
for(Status status:result.statuses)
results.add(new SearchResult(status));
}
prevDisplayItems=new ArrayList<>(displayItems);
unfilteredResults=results;
if (getActivity() == null) return;
onDataLoaded(filterSearchResults(results), false);
}
@Override
public void onError(ErrorResponse error){
resetEmptyText();
currentRequest=null;
Activity a=getActivity();
if(a==null)
return;
error.showToast(a);
if(progressVisibilityListener!=null)
progressVisibilityListener.onProgressVisibilityChanged(false);
}
})
.exec(accountID);
type=null;
}
if(currentQuery==null){
dataLoaded();
return;
}
currentRequest=new GetSearchResults(currentQuery, type, true)
.setCallback(new Callback<>(){
@Override
public void onSuccess(SearchResults result){
ArrayList<SearchResult> results=new ArrayList<>();
if(result.accounts!=null){
for(Account acc:result.accounts)
results.add(new SearchResult(acc));
}
if(result.hashtags!=null){
for(Hashtag tag:result.hashtags)
results.add(new SearchResult(tag));
}
if(result.statuses!=null){
for(Status status:result.statuses)
results.add(new SearchResult(status));
}
prevDisplayItems=new ArrayList<>(displayItems);
unfilteredResults=results;
onDataLoaded(filterSearchResults(results), false);
}
@Override
public void onError(ErrorResponse error){
currentRequest=null;
Activity a=getActivity();
if(a==null)
return;
error.showToast(a);
}
})
.exec(accountID);
}
@Override
@ -185,86 +168,30 @@ public class SearchFragment extends BaseStatusListFragment<SearchResult> {
return;
}
UiUtils.updateList(prevDisplayItems, displayItems, list, adapter, (i1, i2)->i1.parentID.equals(i2.parentID) && i1.index==i2.index && i1.getType()==i2.getType());
boolean recent=isInRecentMode() && !displayItems.isEmpty();
if(recent!=headerAdapter.isVisible())
headerAdapter.setVisible(recent);
imgLoader.forceUpdateImages();
prevDisplayItems=null;
}
@Override
protected void onDataLoaded(List<SearchResult> d, boolean more){
super.onDataLoaded(d, more);
if(progressVisibilityListener!=null)
progressVisibilityListener.onProgressVisibilityChanged(false);
}
@Override
public void onViewCreated(View view, Bundle savedInstanceState){
super.onViewCreated(view, savedInstanceState);
tabLayout=view.findViewById(R.id.tabbar);
tabLayout.setTabTextSize(V.dp(16));
tabLayout.setTabTextColors(UiUtils.getThemeColor(getActivity(), R.attr.colorTabInactive), UiUtils.getThemeColor(getActivity(), android.R.attr.textColorPrimary));
tabLayout.addTab(tabLayout.newTab().setText(R.string.search_all));
tabLayout.addTab(tabLayout.newTab().setText(R.string.search_people));
tabLayout.addTab(tabLayout.newTab().setText(R.string.hashtags));
tabLayout.addTab(tabLayout.newTab().setText(R.string.posts));
for(int i=0;i<tabLayout.getTabCount();i++){
tabLayout.getTabAt(i).view.textView.setAllCaps(true);
}
tabLayout.addOnTabSelectedListener(new TabLayout.OnTabSelectedListener(){
@Override
public void onTabSelected(TabLayout.Tab tab){
setFilter(switch(tab.getPosition()){
case 0 -> EnumSet.allOf(SearchResult.Type.class);
case 1 -> EnumSet.of(SearchResult.Type.ACCOUNT);
case 2 -> EnumSet.of(SearchResult.Type.HASHTAG);
case 3 -> EnumSet.of(SearchResult.Type.STATUS);
default -> throw new IllegalStateException("Unexpected value: "+tab.getPosition());
});
}
@Override
public void onTabUnselected(TabLayout.Tab tab){
}
@Override
public void onTabReselected(TabLayout.Tab tab){
scrollToTop();
}
});
}
@Override
protected RecyclerView.Adapter getAdapter(){
View header=getActivity().getLayoutInflater().inflate(R.layout.item_recent_searches_header, list, false);
header.findViewById(R.id.clear).setOnClickListener(this::onClearRecentClick);
MergeRecyclerAdapter adapter=new MergeRecyclerAdapter();
adapter.addAdapter(headerAdapter=new HideableSingleViewRecyclerAdapter(header));
adapter.addAdapter(super.getAdapter());
headerAdapter.setVisible(isInRecentMode());
return adapter;
}
public void setQuery(String q){
if(Objects.equals(q, currentQuery) || q.isBlank())
public void setQuery(String q, SearchResult.Type filter){
if(q.isBlank())
return;
if(currentRequest!=null){
currentRequest.cancel();
currentRequest=null;
}
currentQuery=q;
if(filter==null)
currentFilter=EnumSet.allOf(SearchResult.Type.class);
else
currentFilter=EnumSet.of(filter);
refreshing=true;
doLoadData(0, 0);
loadData();
}
private void setFilter(EnumSet<SearchResult.Type> filter){
if(filter.equals(currentFilter))
return;
currentFilter=filter;
if(isInRecentMode())
return;
// This can be optimized by not rebuilding display items every time filter is changed, but I'm too lazy
prevDisplayItems=new ArrayList<>(displayItems);
refreshing=true;
@ -286,23 +213,6 @@ public class SearchFragment extends BaseStatusListFragment<SearchResult> {
return null;
}
private void onClearRecentClick(View v){
AccountSessionManager.getInstance().getAccount(accountID).getCacheController().clearRecentSearches();
if(isInRecentMode()){
prevDisplayItems=new ArrayList<>(displayItems);
refreshing=true;
onDataLoaded(unfilteredResults=Collections.emptyList(), false);
}
}
private boolean isInRecentMode(){
return TextUtils.isEmpty(currentQuery);
}
public void setProgressVisibilityListener(ProgressVisibilityListener progressVisibilityListener){
this.progressVisibilityListener=progressVisibilityListener;
}
@Override
public void onScrollStarted(){
super.onScrollStarted();

View File

@ -0,0 +1,574 @@
package org.joinmastodon.android.fragments.discover;
import android.animation.Animator;
import android.animation.AnimatorListenerAdapter;
import android.animation.AnimatorSet;
import android.animation.ObjectAnimator;
import android.app.Activity;
import android.app.Fragment;
import android.graphics.Outline;
import android.graphics.Rect;
import android.graphics.drawable.Drawable;
import android.graphics.drawable.LayerDrawable;
import android.net.Uri;
import android.os.Build;
import android.os.Bundle;
import android.text.TextUtils;
import android.view.RoundedCorner;
import android.view.View;
import android.view.ViewGroup;
import android.view.ViewOutlineProvider;
import android.view.WindowInsets;
import android.view.animation.AnimationUtils;
import android.view.inputmethod.InputMethodManager;
import android.widget.Button;
import android.widget.TextView;
import android.widget.Toast;
import android.widget.Toolbar;
import org.joinmastodon.android.MainActivity;
import org.joinmastodon.android.R;
import org.joinmastodon.android.api.requests.search.GetSearchResults;
import org.joinmastodon.android.api.session.AccountSessionManager;
import org.joinmastodon.android.fragments.MastodonRecyclerFragment;
import org.joinmastodon.android.fragments.ProfileFragment;
import org.joinmastodon.android.model.Relationship;
import org.joinmastodon.android.model.SearchResult;
import org.joinmastodon.android.model.SearchResults;
import org.joinmastodon.android.model.viewmodel.ListItem;
import org.joinmastodon.android.model.viewmodel.SearchResultViewModel;
import org.joinmastodon.android.ui.DividerItemDecoration;
import org.joinmastodon.android.ui.SearchViewHelper;
import org.joinmastodon.android.ui.adapters.GenericListItemsAdapter;
import org.joinmastodon.android.ui.utils.HideableSingleViewRecyclerAdapter;
import org.joinmastodon.android.ui.utils.UiUtils;
import org.joinmastodon.android.ui.viewholders.AccountViewHolder;
import org.joinmastodon.android.ui.viewholders.SimpleListItemViewHolder;
import org.parceler.Parcels;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.function.BiConsumer;
import java.util.function.Function;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import androidx.annotation.Keep;
import androidx.annotation.NonNull;
import androidx.annotation.RequiresApi;
import androidx.recyclerview.widget.RecyclerView;
import me.grishka.appkit.Nav;
import me.grishka.appkit.api.SimpleCallback;
import me.grishka.appkit.fragments.CustomTransitionsFragment;
import me.grishka.appkit.fragments.OnBackPressedListener;
import me.grishka.appkit.imageloader.ImageLoaderRecyclerAdapter;
import me.grishka.appkit.imageloader.requests.ImageLoaderRequest;
import me.grishka.appkit.utils.MergeRecyclerAdapter;
import me.grishka.appkit.utils.V;
import me.grishka.appkit.views.UsableRecyclerView;
public class SearchQueryFragment extends MastodonRecyclerFragment<SearchResultViewModel> implements CustomTransitionsFragment, OnBackPressedListener{
private static final Pattern HASHTAG_REGEX=Pattern.compile("^(\\w*[a-zA-Z·]\\w*)$", Pattern.CASE_INSENSITIVE);
private static final Pattern USERNAME_REGEX=Pattern.compile("^@?([a-z0-9_-]+)(@[^\\s]+)?$", Pattern.CASE_INSENSITIVE);
private MergeRecyclerAdapter mergeAdapter=new MergeRecyclerAdapter();
private HideableSingleViewRecyclerAdapter recentsHeader;
private ListItem<Void> openUrlItem, goToHashtagItem, goToAccountItem, goToStatusSearchItem, goToAccountSearchItem;
private ArrayList<ListItem<Void>> topOptions=new ArrayList<>();
private GenericListItemsAdapter<Void> topOptionsAdapter;
private String accountID;
private SearchViewHelper searchViewHelper;
private String currentQuery;
private LayerDrawable navigationIcon;
private Drawable searchIcon, backIcon;
public SearchQueryFragment(){
super(20);
}
@Override
public void onCreate(Bundle savedInstanceState){
super.onCreate(savedInstanceState);
accountID=getArguments().getString("account");
setRefreshEnabled(false);
setEmptyText("");
openUrlItem=new ListItem<>(R.string.search_open_url, 0, R.drawable.ic_fluent_link_24_regular, this::onOpenURLClick);
goToHashtagItem=new ListItem<>("", null, R.drawable.ic_fluent_number_symbol_24_regular, this::onGoToHashtagClick);
goToAccountItem=new ListItem<>("", null, R.drawable.ic_fluent_person_24_regular, this::onGoToAccountClick);
goToStatusSearchItem=new ListItem<>("", null, R.drawable.ic_fluent_search_24_regular, this::onGoToStatusSearchClick);
goToAccountSearchItem=new ListItem<>("", null, R.drawable.ic_fluent_people_24_regular, this::onGoToAccountSearchClick);
currentQuery=getArguments().getString("query");
dataLoaded();
doLoadData(0, 0);
}
@Override
protected void doLoadData(int offset, int count){
if(isInRecentMode()){
AccountSessionManager.getInstance().getAccount(accountID).getCacheController().getRecentSearches(results->{
if(getActivity()==null)
return;
onDataLoaded(results.stream().map(sr->{
SearchResultViewModel vm=new SearchResultViewModel(sr, accountID, true);
if(sr.type==SearchResult.Type.HASHTAG){
vm.hashtagItem.onClick=()->openHashtag(sr);
}
return vm;
}).collect(Collectors.toList()), false);
recentsHeader.setVisible(!data.isEmpty());
});
}else{
currentRequest=new GetSearchResults(currentQuery, null, false)
.limit(2)
.setCallback(new SimpleCallback<>(this){
@Override
public void onSuccess(SearchResults result){
onDataLoaded(Stream.of(result.hashtags.stream().map(SearchResult::new), result.accounts.stream().map(SearchResult::new))
.flatMap(Function.identity())
.map(sr->{
SearchResultViewModel vm=new SearchResultViewModel(sr, accountID, false);
if(sr.type==SearchResult.Type.HASHTAG){
vm.hashtagItem.onClick=()->openHashtag(sr);
}
return vm;
})
.collect(Collectors.toList()), false);
recentsHeader.setVisible(false);
}
})
.exec(accountID);
}
}
@Override
protected RecyclerView.Adapter<?> getAdapter(){
View header=getActivity().getLayoutInflater().inflate(R.layout.display_item_section_header, list, false);
TextView title=header.findViewById(R.id.title);
Button action=header.findViewById(R.id.action_btn);
title.setText(R.string.recent_searches);
action.setText(R.string.clear_all);
action.setOnClickListener(v->onClearRecentClick());
recentsHeader=new HideableSingleViewRecyclerAdapter(header);
mergeAdapter.addAdapter(recentsHeader);
mergeAdapter.addAdapter(topOptionsAdapter=new GenericListItemsAdapter<>(topOptions));
mergeAdapter.addAdapter(new SearchResultsAdapter());
return mergeAdapter;
}
@Override
public void onViewCreated(View view, Bundle savedInstanceState){
searchViewHelper=new SearchViewHelper(getActivity(), getToolbarContext(), getString(R.string.search_mastodon));
searchViewHelper.setListeners(this::onQueryChanged, this::onQueryChangedNoDebounce);
searchViewHelper.addDivider(contentView);
searchViewHelper.setEnterCallback(this::onSearchViewEnter);
navigationIcon=new LayerDrawable(new Drawable[]{
searchIcon=getToolbarContext().getResources().getDrawable(R.drawable.ic_fluent_search_24_regular, getToolbarContext().getTheme()).mutate(),
backIcon=getToolbarContext().getResources().getDrawable(R.drawable.ic_fluent_arrow_left_24_regular, getToolbarContext().getTheme()).mutate()
}){
@Override
public Drawable mutate(){
return this;
}
};
super.onViewCreated(view, savedInstanceState);
int color=UiUtils.alphaBlendThemeColors(getActivity(), R.attr.colorM3Surface, R.attr.colorM3Primary, 0.11f);
view.setBackgroundColor(color);
setStatusBarColor(color);
setNavigationBarColor(color);
if(currentQuery!=null){
searchViewHelper.setQuery(currentQuery);
searchIcon.setAlpha(0);
}
list.addItemDecoration(new DividerItemDecoration(getActivity(), R.attr.colorM3OutlineVariant, 1, 0, 0, vh->!isInRecentMode() && vh.getAbsoluteAdapterPosition()==topOptions.size()-1));
}
@Override
protected void onUpdateToolbar(){
super.onUpdateToolbar();
((ViewGroup.MarginLayoutParams)getToolbar().getLayoutParams()).topMargin=V.dp(8);
searchViewHelper.install(getToolbar());
}
@Override
protected boolean wantsElevationOnScrollEffect(){
return false;
}
private void onQueryChanged(String q){
currentQuery=q;
if(currentRequest!=null){
currentRequest.cancel();
currentRequest=null;
}
refreshing=true;
doLoadData(0, 0);
}
private void onQueryChangedNoDebounce(String q){
updateTopOptions(q);
if(!TextUtils.isEmpty(q)){
recentsHeader.setVisible(false);
}
data.clear();
mergeAdapter.notifyDataSetChanged();
}
private void updateTopOptions(String q){
topOptions.clear();
// https://github.com/mastodon/mastodon/blob/a985d587e13494b78ef2879e4d97f78a2df693db/app/javascript/mastodon/features/compose/components/search.jsx#L233
String trimmedValue=q.trim();
if(trimmedValue.length()>0){
boolean couldBeURL=trimmedValue.startsWith("https://") && !trimmedValue.contains(" ");
if(couldBeURL){
topOptions.add(openUrlItem);
}
boolean couldBeHashtag=(trimmedValue.startsWith("#") && trimmedValue.length()>1 && !trimmedValue.contains(" ")) || HASHTAG_REGEX.matcher(trimmedValue).find();
if(couldBeHashtag){
String tag=trimmedValue.startsWith("#") ? trimmedValue.substring(1) : trimmedValue;
goToHashtagItem.title=getString(R.string.posts_matching_hashtag, "#"+tag);
topOptions.add(goToHashtagItem);
}
Matcher usernameMatcher=USERNAME_REGEX.matcher(trimmedValue);
if(usernameMatcher.find()){
String username="@"+usernameMatcher.group(1);
String atDomain=usernameMatcher.group(2);
if(atDomain==null){
username+="@"+AccountSessionManager.get(accountID).domain;
}
goToAccountItem.title=getString(R.string.search_go_to_account, username);
topOptions.add(goToAccountItem);
}
goToStatusSearchItem.title=getString(R.string.posts_matching_string, trimmedValue);
topOptions.add(goToStatusSearchItem);
goToAccountSearchItem.title=getString(R.string.accounts_matching_string, trimmedValue);
topOptions.add(goToAccountSearchItem);
}
topOptionsAdapter.notifyDataSetChanged();
}
@Override
public Animator onCreateEnterTransition(View prev, View container){
return createTransition(prev, container, true);
}
@Override
public Animator onCreateExitTransition(View prev, View container){
return createTransition(prev, container, false);
}
@Override
public boolean wantsCustomNavigationIcon(){
return true;
}
@Override
protected Drawable getNavigationIconDrawable(){
return navigationIcon;
}
@Override
protected void onShown(){
super.onShown();
getActivity().getSystemService(InputMethodManager.class).showSoftInput(searchViewHelper.getSearchEdit(), 0);
}
@Override
protected void onHidden(){
super.onHidden();
getActivity().getSystemService(InputMethodManager.class).hideSoftInputFromWindow(getActivity().getWindow().getDecorView().getWindowToken(), 0);
}
@RequiresApi(api = Build.VERSION_CODES.S)
private float getScreenCornerRadius(WindowInsets insets, int pos){
RoundedCorner corner=insets.getRoundedCorner(pos);
if(corner==null)
return 0;
return corner.getRadius();
}
private Animator createTransition(View prev, View container, boolean enter){
int[] loc={0, 0};
View searchBtn=prev.findViewById(R.id.search_wrap);
searchBtn.getLocationInWindow(loc);
int btnLeft=loc[0], btnTop=loc[1];
container.getLocationInWindow(loc);
int offX=btnLeft-loc[0], offY=btnTop-loc[1];
float screenRadius;
if(Build.VERSION.SDK_INT>=Build.VERSION_CODES.S){
WindowInsets insets=container.getRootWindowInsets();
screenRadius=Math.min(
Math.min(getScreenCornerRadius(insets, RoundedCorner.POSITION_TOP_LEFT), getScreenCornerRadius(insets, RoundedCorner.POSITION_TOP_RIGHT)),
Math.min(getScreenCornerRadius(insets, RoundedCorner.POSITION_BOTTOM_LEFT), getScreenCornerRadius(insets, RoundedCorner.POSITION_BOTTOM_RIGHT))
);
}else{
screenRadius=0;
}
float buttonRadius=V.dp(26);
Rect buttonBounds=new Rect(offX, offY, offX+searchBtn.getWidth(), offY+searchBtn.getHeight());
Rect containerBounds=new Rect(0, 0, container.getWidth(), container.getHeight());
AnimatableOutlineProvider outlineProvider=new AnimatableOutlineProvider(enter ? buttonBounds : containerBounds, enter ? containerBounds : buttonBounds, enter ? buttonRadius : screenRadius);
container.setOutlineProvider(outlineProvider);
container.setClipToOutline(true);
AnimatorSet set=new AnimatorSet();
ObjectAnimator boundsAnim;
Toolbar toolbar=getToolbar();
float toolbarTX=offX-toolbar.getX();
float toolbarTY=offY-toolbar.getY()+(searchBtn.getHeight()-toolbar.getHeight())/2f;
ArrayList<Animator> anims=new ArrayList<>();
anims.add(boundsAnim=ObjectAnimator.ofFloat(outlineProvider, "boundsFraction", 0f, 1f));
anims.add(ObjectAnimator.ofFloat(outlineProvider, "radius", enter ? buttonRadius : screenRadius, enter ? screenRadius : buttonRadius));
anims.add(ObjectAnimator.ofFloat(toolbar, View.TRANSLATION_X, enter ? toolbarTX : 0, enter ? 0 : toolbarTX));
anims.add(ObjectAnimator.ofFloat(toolbar, View.TRANSLATION_Y, enter ? toolbarTY : 0, enter ? 0 : toolbarTY));
anims.add(ObjectAnimator.ofFloat(searchViewHelper.getDivider(), View.ALPHA, enter ? 0 : 1, enter ? 1 : 0));
View parentContent=prev.findViewById(R.id.discover_content);
View parentContentParent=(View) parentContent.getParent();
parentContentParent.setBackgroundColor(UiUtils.getThemeColor(getActivity(), R.attr.colorM3Surface));
if(enter){
anims.add(ObjectAnimator.ofFloat(contentWrap, View.TRANSLATION_Y, V.dp(-16), 0));
}else{
}
anims.add(ObjectAnimator.ofFloat(contentWrap, View.ALPHA, enter ? 0 : 1, enter ? 1 : 0));
for(Animator anim:anims){
anim.setDuration(enter ? 700 : 300);
}
if(TextUtils.isEmpty(currentQuery)){
anims.add(ObjectAnimator.ofInt(searchIcon, "alpha", enter ? 255 : 0, enter ? 0 : 255).setDuration(200));
anims.add(ObjectAnimator.ofInt(backIcon, "alpha", enter ? 0 : 255, enter ? 255 : 0).setDuration(200));
}
ObjectAnimator parentContentFade;
anims.add(parentContentFade=ObjectAnimator.ofFloat(parentContent, View.ALPHA, enter ? 1 : 0, enter ? 0 : 1).setDuration(enter ? 350 : 250));
if(!enter){
parentContentFade.setStartDelay(50);
ObjectAnimator parentContentTY;
anims.add(parentContentTY=ObjectAnimator.ofFloat(parentContent, View.TRANSLATION_Y, V.dp(16), 0).setDuration(250));
parentContentTY.setStartDelay(50);
}
set.playTogether(anims);
set.setInterpolator(AnimationUtils.loadInterpolator(getActivity(), R.interpolator.m3_sys_motion_easing_emphasized_decelerate));
set.addListener(new AnimatorListenerAdapter(){
@Override
public void onAnimationEnd(Animator animation){
container.setOutlineProvider(null);
container.setClipToOutline(false);
parentContentParent.setBackground(null);
}
});
boundsAnim.addUpdateListener(animation->{
container.invalidateOutline();
navigationIcon.invalidateSelf();
});
return set;
}
private void openHashtag(SearchResult res){
UiUtils.openHashtagTimeline(getActivity(), accountID, res.hashtag.name, res.hashtag.following);
AccountSessionManager.getInstance().getAccount(accountID).getCacheController().putRecentSearch(res);
}
private boolean isInRecentMode(){
return TextUtils.isEmpty(currentQuery);
}
private void onSearchViewEnter(){
deliverResult(currentQuery, null);
}
private void onOpenURLClick(){
UiUtils.openURL(getContext(), accountID, searchViewHelper.getQuery(), false);
}
private void onGoToHashtagClick(){
String q=searchViewHelper.getQuery();
if(q.startsWith("#"))
q=q.substring(1);
UiUtils.openHashtagTimeline(getActivity(), accountID, q, null);
}
private void onGoToAccountClick(){
String q=searchViewHelper.getQuery();
if(!q.startsWith("@")){
q="@"+q;
}
if(q.lastIndexOf('@')==0){
q+="@"+AccountSessionManager.get(accountID).domain;
}
UiUtils.lookupAccountHandle(getContext(), accountID, q, (clazz, args) -> {
if (!args.containsKey("profileAccount")) {
Toast.makeText(getContext(), R.string.no_search_results, Toast.LENGTH_SHORT).show();
return;
}
Nav.go((Activity) getContext(), ProfileFragment.class, args);
}).ifPresent(progress -> progress.wrapProgress((Activity) getContext(), R.string.loading, true));
}
private void onGoToStatusSearchClick(){
deliverResult(searchViewHelper.getQuery(), SearchResult.Type.STATUS);
}
private void onGoToAccountSearchClick(){
deliverResult(searchViewHelper.getQuery(), SearchResult.Type.ACCOUNT);
}
private void onClearRecentClick(){
AccountSessionManager.getInstance().getAccount(accountID).getCacheController().clearRecentSearches();
if(isInRecentMode()){
data.clear();
recentsHeader.setVisible(false);
mergeAdapter.notifyDataSetChanged();
}
}
private void deliverResult(String query, SearchResult.Type typeFilter){
Bundle res=new Bundle();
res.putString("query", query);
if(typeFilter!=null)
res.putInt("filter", typeFilter.ordinal());
setResult(true, res);
Nav.finish(this);
}
@Override
public boolean onBackPressed(){
String initialQuery=getArguments().getString("query");
searchViewHelper.setQuery(TextUtils.isEmpty(initialQuery) ? "" : initialQuery);
currentQuery=initialQuery;
return false;
}
private static class AnimatableOutlineProvider extends ViewOutlineProvider{
private float boundsFraction, radius;
private final Rect boundsFrom, boundsTo;
private AnimatableOutlineProvider(Rect boundsFrom, Rect boundsTo, float radius){
this.boundsFrom=boundsFrom;
this.boundsTo=boundsTo;
this.radius=radius;
}
@Override
public void getOutline(View view, Outline outline){
outline.setRoundRect(
UiUtils.lerp(boundsFrom.left, boundsTo.left, boundsFraction),
UiUtils.lerp(boundsFrom.top, boundsTo.top, boundsFraction),
UiUtils.lerp(boundsFrom.right, boundsTo.right, boundsFraction),
UiUtils.lerp(boundsFrom.bottom, boundsTo.bottom, boundsFraction),
radius
);
}
@Keep
public float getBoundsFraction(){
return boundsFraction;
}
@Keep
public void setBoundsFraction(float boundsFraction){
this.boundsFraction=boundsFraction;
}
@Keep
public float getRadius(){
return radius;
}
@Keep
public void setRadius(float radius){
this.radius=radius;
}
}
private class SearchResultsAdapter extends UsableRecyclerView.Adapter<RecyclerView.ViewHolder> implements ImageLoaderRecyclerAdapter{
public SearchResultsAdapter(){
super(imgLoader);
}
@NonNull
@Override
public RecyclerView.ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType){
if(viewType==R.id.list_item_account){
return new CustomAccountViewHolder(SearchQueryFragment.this, parent, null);
}else if(viewType==R.id.list_item_simple){
return new SimpleListItemViewHolder(parent.getContext(), parent);
}
throw new IllegalArgumentException();
}
@Override
public void onBindViewHolder(@NonNull RecyclerView.ViewHolder holder, int position){
if(holder instanceof CustomAccountViewHolder avh){
avh.bind(data.get(position).account);
avh.searchResult=data.get(position).result;
}else if(holder instanceof SimpleListItemViewHolder ivh){
ivh.bind(data.get(position).hashtagItem);
}
}
@Override
public int getItemCount(){
return data.size();
}
@Override
public int getItemViewType(int position){
return switch(data.get(position).result.type){
case ACCOUNT -> R.id.list_item_account;
case HASHTAG -> R.id.list_item_simple;
default -> throw new IllegalStateException("Unexpected value: "+data.get(position).result.type);
};
}
@Override
public int getImageCountForItem(int position){
SearchResultViewModel vm=data.get(position);
if(vm.account!=null)
return vm.account.emojiHelper.getImageCount()+1;
return 0;
}
@Override
public ImageLoaderRequest getImageRequest(int position, int image){
SearchResultViewModel vm=data.get(position);
if(vm.account!=null){
if(image==0)
return vm.account.avaRequest;
return vm.account.emojiHelper.getImageRequest(image-1);
}
return null;
}
}
private class CustomAccountViewHolder extends AccountViewHolder{
public SearchResult searchResult;
public CustomAccountViewHolder(Fragment fragment, ViewGroup list, HashMap<String, Relationship> relationships){
super(fragment, list, relationships);
setStyle(AccessoryType.NONE, false);
}
@Override
public void onClick(){
super.onClick();
AccountSessionManager.getInstance().getAccount(accountID).getCacheController().putRecentSearch(searchResult);
}
}
}

View File

@ -1,9 +1,5 @@
package org.joinmastodon.android.fragments.discover;
import static org.joinmastodon.android.ui.displayitems.HashtagStatusDisplayItem.Holder.withHistoryParams;
import static org.joinmastodon.android.ui.displayitems.HashtagStatusDisplayItem.Holder.withoutHistoryParams;
import android.net.Uri;
import android.os.Bundle;
import android.view.View;
import android.view.ViewGroup;
@ -11,29 +7,26 @@ import android.widget.TextView;
import org.joinmastodon.android.R;
import org.joinmastodon.android.api.requests.trends.GetTrendingHashtags;
import org.joinmastodon.android.fragments.IsOnTop;
import org.joinmastodon.android.fragments.RecyclerFragment;
import org.joinmastodon.android.fragments.ScrollableToTop;
import org.joinmastodon.android.model.Hashtag;
import org.joinmastodon.android.ui.DividerItemDecoration;
import org.joinmastodon.android.ui.utils.DiscoverInfoBannerHelper;
import org.joinmastodon.android.ui.utils.UiUtils;
import org.joinmastodon.android.ui.views.HashtagChartView;
import org.joinmastodon.android.utils.ProvidesAssistContent;
import java.util.List;
import androidx.annotation.NonNull;
import androidx.recyclerview.widget.RecyclerView;
import me.grishka.appkit.api.SimpleCallback;
import me.grishka.appkit.fragments.BaseRecyclerFragment;
import me.grishka.appkit.utils.BindableViewHolder;
import me.grishka.appkit.views.UsableRecyclerView;
public class DiscoverHashtagsFragment extends RecyclerFragment<Hashtag> implements ScrollableToTop, IsOnTop, ProvidesAssistContent.ProvidesWebUri {
public class TrendingHashtagsFragment extends BaseRecyclerFragment<Hashtag> implements ScrollableToTop{
private String accountID;
private DiscoverInfoBannerHelper bannerHelper=new DiscoverInfoBannerHelper(DiscoverInfoBannerHelper.BannerType.TRENDING_HASHTAGS);
public DiscoverHashtagsFragment(){
public TrendingHashtagsFragment(){
super(10);
}
@ -49,7 +42,6 @@ public class DiscoverHashtagsFragment extends RecyclerFragment<Hashtag> implemen
.setCallback(new SimpleCallback<>(this){
@Override
public void onSuccess(List<Hashtag> result){
if (getActivity() == null) return;
onDataLoaded(result, false);
}
})
@ -61,33 +53,11 @@ public class DiscoverHashtagsFragment extends RecyclerFragment<Hashtag> implemen
return new HashtagsAdapter();
}
@Override
public void onViewCreated(View view, Bundle savedInstanceState){
super.onViewCreated(view, savedInstanceState);
list.addItemDecoration(new DividerItemDecoration(getActivity(), R.attr.colorPollVoted, .5f, 16, 16));
bannerHelper.maybeAddBanner(contentWrap);
}
@Override
public void scrollToTop(){
smoothScrollRecyclerViewToTop(list);
}
@Override
public boolean isOnTop() {
return isRecyclerViewOnTop(list);
}
@Override
public String getAccountID() {
return accountID;
}
@Override
public Uri getWebUri(Uri.Builder base) {
return isInstanceAkkoma() ? null : base.path("/explore/tags").build();
}
private class HashtagsAdapter extends RecyclerView.Adapter<HashtagViewHolder>{
@NonNull
@Override
@ -123,11 +93,9 @@ public class DiscoverHashtagsFragment extends RecyclerFragment<Hashtag> implemen
if (item.history == null || item.history.isEmpty()) {
subtitle.setText(null);
chart.setVisibility(View.GONE);
title.setLayoutParams(withoutHistoryParams);
return;
}
chart.setVisibility(View.VISIBLE);
title.setLayoutParams(withHistoryParams);
int numPeople=item.history.get(0).accounts;
if(item.history.size()>1)
numPeople+=item.history.get(1).accounts;

View File

@ -3,7 +3,6 @@ package org.joinmastodon.android.fragments.onboarding;
import android.annotation.SuppressLint;
import android.content.ActivityNotFoundException;
import android.content.Intent;
import android.os.Build;
import android.os.Bundle;
import android.os.Handler;
import android.os.Looper;
@ -23,8 +22,7 @@ import org.joinmastodon.android.api.requests.accounts.UpdateAccountCredentials;
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.fragments.HomeFragment;
import org.joinmastodon.android.fragments.SettingsFragment;
import org.joinmastodon.android.fragments.settings.SettingsMainFragment;
import org.joinmastodon.android.model.Account;
import org.joinmastodon.android.ui.AccountSwitcherSheet;
import org.joinmastodon.android.ui.utils.UiUtils;
@ -38,7 +36,6 @@ import me.grishka.appkit.api.APIRequest;
import me.grishka.appkit.api.Callback;
import me.grishka.appkit.api.ErrorResponse;
import me.grishka.appkit.fragments.ToolbarFragment;
import me.grishka.appkit.utils.V;
public class AccountActivationFragment extends ToolbarFragment{
private String accountID;
@ -50,6 +47,7 @@ public class AccountActivationFragment extends ToolbarFragment{
private APIRequest currentRequest;
private Runnable resendTimer=this::updateResendTimer;
private long lastResendTime;
private boolean visible;
@Override
public void onCreate(Bundle savedInstanceState){
@ -70,7 +68,7 @@ public class AccountActivationFragment extends ToolbarFragment{
openEmailBtn.setOnLongClickListener(v->{
Bundle args=new Bundle();
args.putString("account", accountID);
Nav.go(getActivity(), SettingsFragment.class, args);
Nav.go(getActivity(), SettingsMainFragment.class, args);
return true;
});
resendBtn=view.findViewById(R.id.btn_resend);
@ -108,24 +106,20 @@ public class AccountActivationFragment extends ToolbarFragment{
@Override
public void onApplyWindowInsets(WindowInsets insets){
if(Build.VERSION.SDK_INT>=27){
int inset=insets.getSystemWindowInsetBottom();
contentView.setPadding(0, 0, 0, inset>0 ? Math.max(inset, V.dp(36)) : 0);
super.onApplyWindowInsets(insets.replaceSystemWindowInsets(insets.getSystemWindowInsetLeft(), insets.getSystemWindowInsetTop(), insets.getSystemWindowInsetRight(), 0));
}else{
super.onApplyWindowInsets(insets.replaceSystemWindowInsets(insets.getSystemWindowInsetLeft(), insets.getSystemWindowInsetTop(), insets.getSystemWindowInsetRight(), insets.getSystemWindowInsetBottom()));
}
super.onApplyWindowInsets(UiUtils.applyBottomInsetToFixedView(contentView, insets));
}
@Override
protected void onShown(){
super.onShown();
visible=true;
tryGetAccount();
}
@Override
protected void onHidden(){
super.onHidden();
visible=false;
if(currentRequest!=null){
currentRequest.cancel();
currentRequest=null;
@ -238,6 +232,8 @@ public class AccountActivationFragment extends ToolbarFragment{
}
private void proceed(){
if(!visible)
return;
Bundle args=new Bundle();
args.putString("account", accountID);
// Nav.goClearingStack(getActivity(), HomeFragment.class, args);

View File

@ -122,7 +122,7 @@ public class CustomWelcomeFragment extends InstanceCatalogFragment {
@Override
public void onViewCreated(View view, Bundle savedInstanceState) {
super.onViewCreated(view, savedInstanceState);
view.setBackgroundColor(UiUtils.getThemeColor(getActivity(), R.attr.colorWindowBackground));
view.setBackgroundColor(UiUtils.getThemeColor(getActivity(), R.attr.colorM3Surface));
list.setItemAnimator(new BetterItemAnimator());
((UsableRecyclerView) list).setSelector(null);
}
@ -131,17 +131,15 @@ public class CustomWelcomeFragment extends InstanceCatalogFragment {
protected void doLoadData(int offset, int count) {}
@Override
protected RecyclerView.Adapter getAdapter(){
protected RecyclerView.Adapter<?> getAdapter(){
headerView=getActivity().getLayoutInflater().inflate(R.layout.header_welcome_custom, list, false);
searchEdit=headerView.findViewById(R.id.search_edit);
searchEdit.setOnEditorActionListener(this::onSearchEnterPressed);
headerView.findViewById(R.id.more).setVisibility(View.GONE);
headerView.findViewById(R.id.visibility).setVisibility(View.GONE);
headerView.findViewById(R.id.separator).setVisibility(View.GONE);
headerView.findViewById(R.id.timestamp).setVisibility(View.GONE);
headerView.findViewById(R.id.unread_indicator).setVisibility(View.GONE);
((TextView) headerView.findViewById(R.id.username)).setText(R.string.sk_app_username);
((TextView) headerView.findViewById(R.id.time_and_username)).setText(R.string.sk_app_username);
((TextView) headerView.findViewById(R.id.name)).setText(R.string.sk_app_name);
((ImageView) headerView.findViewById(R.id.avatar)).setImageDrawable(getActivity().getDrawable(R.mipmap.ic_launcher));
((FragmentStackActivity) getActivity()).invalidateSystemBarColors(this);
@ -203,7 +201,6 @@ public class CustomWelcomeFragment extends InstanceCatalogFragment {
private class InstanceViewHolder extends BindableViewHolder<CatalogInstance> implements UsableRecyclerView.Clickable{
private final TextView title, description, userCount, lang;
private final RadioButton radioButton;
public InstanceViewHolder(){
super(getActivity(), R.layout.item_instance_custom, list);
@ -211,7 +208,6 @@ public class CustomWelcomeFragment extends InstanceCatalogFragment {
description=findViewById(R.id.description);
userCount=findViewById(R.id.user_count);
lang=findViewById(R.id.lang);
radioButton=findViewById(R.id.radiobtn);
if(Build.VERSION.SDK_INT<Build.VERSION_CODES.N){
UiUtils.fixCompoundDrawableTintOnAndroid6(userCount);
UiUtils.fixCompoundDrawableTintOnAndroid6(lang);
@ -231,22 +227,10 @@ public class CustomWelcomeFragment extends InstanceCatalogFragment {
userCount.setText(UiUtils.abbreviateNumber(item.totalUsers));
lang.setText(item.language.toUpperCase());
}
radioButton.setChecked(chosenInstance==item);
radioButton.setVisibility(View.GONE);
}
@Override
public void onClick(){
if(chosenInstance!=null){
int idx=filteredData.indexOf(chosenInstance);
if(idx!=-1){
RecyclerView.ViewHolder holder=list.findViewHolderForAdapterPosition(mergeAdapter.getPositionForAdapter(adapter)+idx);
if(holder instanceof InstanceViewHolder ivh){
ivh.radioButton.setChecked(false);
}
}
}
radioButton.setChecked(true);
if(chosenInstance==null)
nextButton.setEnabled(true);
chosenInstance=item;

View File

@ -75,7 +75,7 @@ public class GoogleMadeMeAddThisFragment extends ToolbarFragment{
@Override
public void onAttach(Activity activity){
super.onAttach(activity);
setNavigationBarColor(UiUtils.getThemeColor(activity, R.attr.colorWindowBackground));
setNavigationBarColor(UiUtils.getThemeColor(activity, R.attr.colorM3Surface));
instance=Parcels.unwrap(getArguments().getParcelable("instance"));
items.add(new Item("Mastodon for Android Privacy Policy", getString(R.string.privacy_policy_explanation), "joinmastodon.org", "https://joinmastodon.org/android/privacy", "https://joinmastodon.org/favicon-32x32.png"));
@ -123,16 +123,12 @@ public class GoogleMadeMeAddThisFragment extends ToolbarFragment{
@Override
public void onViewCreated(View view, Bundle savedInstanceState){
super.onViewCreated(view, savedInstanceState);
setStatusBarColor(UiUtils.getThemeColor(getActivity(), R.attr.colorM3Background));
view.setBackgroundColor(UiUtils.getThemeColor(getActivity(), R.attr.colorM3Background));
list.addOnScrollListener(onScrollListener=new ElevationOnScrollListener((FragmentRootLinearLayout) view, buttonBar, getToolbar()));
}
@Override
protected void onUpdateToolbar(){
super.onUpdateToolbar();
getToolbar().setBackgroundResource(R.drawable.bg_onboarding_panel);
getToolbar().setElevation(0);
if(onScrollListener!=null){
onScrollListener.setViews(buttonBar, getToolbar());
}
@ -155,13 +151,7 @@ public class GoogleMadeMeAddThisFragment extends ToolbarFragment{
@Override
public void onApplyWindowInsets(WindowInsets insets){
if(Build.VERSION.SDK_INT>=27){
int inset=insets.getSystemWindowInsetBottom();
buttonBar.setPadding(0, 0, 0, inset>0 ? Math.max(inset, V.dp(36)) : 0);
super.onApplyWindowInsets(insets.replaceSystemWindowInsets(insets.getSystemWindowInsetLeft(), insets.getSystemWindowInsetTop(), insets.getSystemWindowInsetRight(), 0));
}else{
super.onApplyWindowInsets(insets.replaceSystemWindowInsets(insets.getSystemWindowInsetLeft(), insets.getSystemWindowInsetTop(), insets.getSystemWindowInsetRight(), insets.getSystemWindowInsetBottom()));
}
super.onApplyWindowInsets(UiUtils.applyBottomInsetToFixedView(buttonBar, insets));
}
private void loadServerPrivacyPolicy(){

View File

@ -17,10 +17,11 @@ import org.joinmastodon.android.R;
import org.joinmastodon.android.api.MastodonAPIController;
import org.joinmastodon.android.api.MastodonErrorResponse;
import org.joinmastodon.android.api.requests.instance.GetInstance;
import org.joinmastodon.android.fragments.RecyclerFragment;
import org.joinmastodon.android.fragments.MastodonRecyclerFragment;
import org.joinmastodon.android.model.Instance;
import org.joinmastodon.android.model.catalog.CatalogInstance;
import org.joinmastodon.android.ui.M3AlertDialogBuilder;
import org.joinmastodon.android.ui.utils.UiUtils;
import org.w3c.dom.Document;
import org.w3c.dom.Element;
import org.w3c.dom.NodeList;
@ -52,7 +53,7 @@ import okhttp3.Call;
import okhttp3.Request;
import okhttp3.Response;
abstract class InstanceCatalogFragment extends RecyclerFragment<CatalogInstance> {
abstract class InstanceCatalogFragment extends MastodonRecyclerFragment<CatalogInstance> {
protected RecyclerView.Adapter adapter;
protected MergeRecyclerAdapter mergeAdapter;
protected CatalogInstance chosenInstance;
@ -227,7 +228,7 @@ abstract class InstanceCatalogFragment extends RecyclerFragment<CatalogInstance>
}
loadingInstanceDomain=null;
showInstanceInfoLoadError(domain, error);
if(fakeInstance!=null){
if(fakeInstance!=null && getActivity()!=null){
fakeInstance.description=getString(R.string.error);
if(filteredData.size()>0 && filteredData.get(0)==fakeInstance){
if(list.findViewHolderForAdapterPosition(1) instanceof BindableViewHolder<?> ivh){
@ -330,13 +331,7 @@ abstract class InstanceCatalogFragment extends RecyclerFragment<CatalogInstance>
@Override
public void onApplyWindowInsets(WindowInsets insets){
if(Build.VERSION.SDK_INT>=27){
int inset=insets.getSystemWindowInsetBottom();
buttonBar.setPadding(0, 0, 0, inset>0 ? Math.max(inset, V.dp(36)) : 0);
super.onApplyWindowInsets(insets.replaceSystemWindowInsets(insets.getSystemWindowInsetLeft(), insets.getSystemWindowInsetTop(), insets.getSystemWindowInsetRight(), 0));
}else{
super.onApplyWindowInsets(insets.replaceSystemWindowInsets(insets.getSystemWindowInsetLeft(), insets.getSystemWindowInsetTop(), insets.getSystemWindowInsetRight(), insets.getSystemWindowInsetBottom()));
}
super.onApplyWindowInsets(UiUtils.applyBottomInsetToFixedView(buttonBar, insets));
}
@Override

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