Browse Source

Merge branch 'develop' into unified-push-upgrade

# Conflicts:
#	app/proguard-rules.pro
#	app/src/main/java/com/keylesspalace/tusky/components/instanceinfo/InstanceInfo.kt
#	app/src/main/java/com/keylesspalace/tusky/components/instanceinfo/InstanceInfoRepository.kt
#	app/src/main/java/com/keylesspalace/tusky/components/systemnotifications/NotificationHelper.kt
#	app/src/main/java/com/keylesspalace/tusky/db/entity/InstanceEntity.kt
unified-push-upgrade
Conny Duck 11 months ago
parent
commit
800fea7d3b
No known key found for this signature in database
  1. 11
      app/build.gradle
  2. 232
      app/lint-baseline.xml
  3. 10
      app/lint.xml
  4. 34
      app/proguard-rules.pro
  5. 1405
      app/schemas/com.keylesspalace.tusky.db.AppDatabase/70.json
  6. 40
      app/src/main/java/com/keylesspalace/tusky/MainActivity.kt
  7. 3
      app/src/main/java/com/keylesspalace/tusky/MainViewModel.kt
  8. 14
      app/src/main/java/com/keylesspalace/tusky/StatusListActivity.kt
  9. 39
      app/src/main/java/com/keylesspalace/tusky/ViewMediaActivity.kt
  10. 46
      app/src/main/java/com/keylesspalace/tusky/adapter/LoadMoreViewHolder.kt
  11. 4
      app/src/main/java/com/keylesspalace/tusky/adapter/LoadStateFooterAdapter.kt
  12. 45
      app/src/main/java/com/keylesspalace/tusky/adapter/PlaceholderViewHolder.kt
  13. 48
      app/src/main/java/com/keylesspalace/tusky/adapter/PollAdapter.kt
  14. 120
      app/src/main/java/com/keylesspalace/tusky/adapter/StatusBaseViewHolder.java
  15. 2
      app/src/main/java/com/keylesspalace/tusky/adapter/StatusDetailedViewHolder.java
  16. 4
      app/src/main/java/com/keylesspalace/tusky/adapter/StatusViewHolder.java
  17. 3
      app/src/main/java/com/keylesspalace/tusky/appstore/Events.kt
  18. 7
      app/src/main/java/com/keylesspalace/tusky/components/account/media/AccountMediaFragment.kt
  19. 7
      app/src/main/java/com/keylesspalace/tusky/components/accountlist/AccountListActivity.kt
  20. 359
      app/src/main/java/com/keylesspalace/tusky/components/accountlist/AccountListFragment.kt
  21. 34
      app/src/main/java/com/keylesspalace/tusky/components/accountlist/AccountListPagingSource.kt
  22. 119
      app/src/main/java/com/keylesspalace/tusky/components/accountlist/AccountListRemoteMediator.kt
  23. 240
      app/src/main/java/com/keylesspalace/tusky/components/accountlist/AccountListViewModel.kt
  24. 33
      app/src/main/java/com/keylesspalace/tusky/components/accountlist/AccountViewData.kt
  25. 117
      app/src/main/java/com/keylesspalace/tusky/components/accountlist/adapter/AccountAdapter.kt
  26. 54
      app/src/main/java/com/keylesspalace/tusky/components/accountlist/adapter/BlocksAdapter.kt
  27. 20
      app/src/main/java/com/keylesspalace/tusky/components/accountlist/adapter/FollowAdapter.kt
  28. 34
      app/src/main/java/com/keylesspalace/tusky/components/accountlist/adapter/FollowRequestsAdapter.kt
  29. 118
      app/src/main/java/com/keylesspalace/tusky/components/accountlist/adapter/MutesAdapter.kt
  30. 42
      app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationPagingAdapter.kt
  31. 2
      app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationViewHolder.java
  32. 39
      app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsFragment.kt
  33. 6
      app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsViewModel.kt
  34. 34
      app/src/main/java/com/keylesspalace/tusky/components/filters/EditFilterActivity.kt
  35. 18
      app/src/main/java/com/keylesspalace/tusky/components/filters/EditFilterViewModel.kt
  36. 2
      app/src/main/java/com/keylesspalace/tusky/components/filters/FiltersAdapter.kt
  37. 1
      app/src/main/java/com/keylesspalace/tusky/components/instanceinfo/InstanceInfo.kt
  38. 3
      app/src/main/java/com/keylesspalace/tusky/components/instanceinfo/InstanceInfoRepository.kt
  39. 16
      app/src/main/java/com/keylesspalace/tusky/components/login/LoginActivity.kt
  40. 11
      app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationTypeMappers.kt
  41. 41
      app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsFragment.kt
  42. 44
      app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsPagingAdapter.kt
  43. 4
      app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsRemoteMediator.kt
  44. 29
      app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsViewModel.kt
  45. 45
      app/src/main/java/com/keylesspalace/tusky/components/notifications/requests/details/NotificationRequestDetailsFragment.kt
  46. 6
      app/src/main/java/com/keylesspalace/tusky/components/notifications/requests/details/NotificationRequestDetailsRemoteMediator.kt
  47. 3
      app/src/main/java/com/keylesspalace/tusky/components/preference/PreferencesActivity.kt
  48. 1
      app/src/main/java/com/keylesspalace/tusky/components/preference/PreferencesFragment.kt
  49. 2
      app/src/main/java/com/keylesspalace/tusky/components/report/ReportViewModel.kt
  50. 4
      app/src/main/java/com/keylesspalace/tusky/components/report/fragments/ReportStatusesFragment.kt
  51. 14
      app/src/main/java/com/keylesspalace/tusky/components/search/SearchViewModel.kt
  52. 69
      app/src/main/java/com/keylesspalace/tusky/components/search/fragments/SearchStatusesFragment.kt
  53. 39
      app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt
  54. 50
      app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelinePagingAdapter.kt
  55. 15
      app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineTypeMappers.kt
  56. 6
      app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/CachedTimelineRemoteMediator.kt
  57. 20
      app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/CachedTimelineViewModel.kt
  58. 8
      app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelineRemoteMediator.kt
  59. 21
      app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelineViewModel.kt
  60. 15
      app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/TimelineViewModel.kt
  61. 2
      app/src/main/java/com/keylesspalace/tusky/components/trending/viewmodel/TrendingTagsViewModel.kt
  62. 2
      app/src/main/java/com/keylesspalace/tusky/components/viewthread/ThreadAdapter.kt
  63. 41
      app/src/main/java/com/keylesspalace/tusky/components/viewthread/ViewThreadFragment.kt
  64. 15
      app/src/main/java/com/keylesspalace/tusky/components/viewthread/ViewThreadViewModel.kt
  65. 4
      app/src/main/java/com/keylesspalace/tusky/components/viewthread/edits/ViewEditsAdapter.kt
  66. 3
      app/src/main/java/com/keylesspalace/tusky/db/AppDatabase.java
  67. 3
      app/src/main/java/com/keylesspalace/tusky/db/entity/InstanceEntity.kt
  68. 18
      app/src/main/java/com/keylesspalace/tusky/di/PlayerModule.kt
  69. 41
      app/src/main/java/com/keylesspalace/tusky/entity/Filter.kt
  70. 26
      app/src/main/java/com/keylesspalace/tusky/entity/FilterV1.kt
  71. 8
      app/src/main/java/com/keylesspalace/tusky/entity/Instance.kt
  72. 6
      app/src/main/java/com/keylesspalace/tusky/entity/Status.kt
  73. 20
      app/src/main/java/com/keylesspalace/tusky/fragment/SFragment.kt
  74. 14
      app/src/main/java/com/keylesspalace/tusky/fragment/ViewImageFragment.kt
  75. 16
      app/src/main/java/com/keylesspalace/tusky/interfaces/StatusActionListener.kt
  76. 20
      app/src/main/java/com/keylesspalace/tusky/network/FilterModel.kt
  77. 17
      app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.kt
  78. 2
      app/src/main/java/com/keylesspalace/tusky/service/SendStatusService.kt
  79. 3
      app/src/main/java/com/keylesspalace/tusky/settings/SettingsConstants.kt
  80. 4
      app/src/main/java/com/keylesspalace/tusky/usecase/TimelineCases.kt
  81. 46
      app/src/main/java/com/keylesspalace/tusky/util/CustomEmojiHelper.kt
  82. 8
      app/src/main/java/com/keylesspalace/tusky/util/LinkHelper.kt
  83. 4
      app/src/main/java/com/keylesspalace/tusky/util/ListStatusAccessibilityDelegate.kt
  84. 7
      app/src/main/java/com/keylesspalace/tusky/util/ListUtils.kt
  85. 13
      app/src/main/java/com/keylesspalace/tusky/util/LocaleManager.kt
  86. 4
      app/src/main/java/com/keylesspalace/tusky/util/RickRoll.kt
  87. 4
      app/src/main/java/com/keylesspalace/tusky/util/ShareShortcutHelper.kt
  88. 27
      app/src/main/java/com/keylesspalace/tusky/util/StatusDisplayOptions.kt
  89. 10
      app/src/main/java/com/keylesspalace/tusky/util/StatusViewHelper.kt
  90. 2
      app/src/main/java/com/keylesspalace/tusky/util/StringUtils.kt
  91. 7
      app/src/main/java/com/keylesspalace/tusky/util/ThemeUtils.kt
  92. 20
      app/src/main/java/com/keylesspalace/tusky/util/ViewDataUtils.kt
  93. 15
      app/src/main/java/com/keylesspalace/tusky/view/ClickableSpanTextView.kt
  94. 209
      app/src/main/java/com/keylesspalace/tusky/view/ConfirmationBottomSheet.kt
  95. 48
      app/src/main/java/com/keylesspalace/tusky/view/EndlessOnScrollListener.kt
  96. 4
      app/src/main/java/com/keylesspalace/tusky/view/GraphView.kt
  97. 4
      app/src/main/java/com/keylesspalace/tusky/viewdata/NotificationViewData.kt
  98. 16
      app/src/main/java/com/keylesspalace/tusky/viewdata/PollViewData.kt
  99. 8
      app/src/main/java/com/keylesspalace/tusky/viewdata/StatusViewData.kt
  100. 12
      app/src/main/res/anim/explode.xml
  101. Some files were not shown because too many files have changed in this diff Show More

11
app/build.gradle

@ -25,13 +25,13 @@ final def CUSTOM_INSTANCE = ""
final def SUPPORT_ACCOUNT_URL = "https://mastodon.social/@Tusky"
android {
compileSdk 35
compileSdk 36
namespace "com.keylesspalace.tusky"
defaultConfig {
applicationId APP_ID
namespace "com.keylesspalace.tusky"
minSdk 24
targetSdk 35
targetSdk 36
versionCode 131
versionName "28.0"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
@ -103,8 +103,11 @@ android {
// Exclude unneeded files added by libraries
packagingOptions.resources.excludes += [
'LICENSE_OFL',
'LICENSE_UNICODE',
'LICENSE_OFL',
'LICENSE_UNICODE',
'META-INF/androidx/**',
'META-INF/NOTICE.md',
'DebugProbesKt.bin'
]
bundle {

232
app/lint-baseline.xml

@ -1,5 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<issues format="6" by="lint 8.8.2" type="baseline" client="gradle" dependencies="false" name="AGP (8.8.2)" variant="all" version="8.8.2">
<issues format="6" by="lint 8.9.1" type="baseline" client="gradle" dependencies="false" name="AGP (8.9.1)" variant="all" version="8.9.1">
<issue
id="GestureBackNavigation"
@ -8,7 +8,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/java/com/keylesspalace/tusky/components/compose/ComposeActivity.kt"
line="1281"
line="1235"
column="28"/>
</issue>
@ -23,6 +23,28 @@
column="32"/>
</issue>
<issue
id="InlinedApi"
message="Field requires API level 29 (current min is 24): `android.text.style.DynamicDrawableSpan#ALIGN_CENTER`"
errorLine1=" builder.setSpan(ImageSpan(drawable, DynamicDrawableSpan.ALIGN_CENTER), builder.length - 2, builder.length - 1, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)"
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/java/com/keylesspalace/tusky/viewdata/PollViewData.kt"
line="74"
column="49"/>
</issue>
<issue
id="InlinedApi"
message="Field requires API level 29 (current min is 24): `android.text.style.DynamicDrawableSpan#ALIGN_CENTER`"
errorLine1=" builder.setSpan(ImageSpan(drawable, DynamicDrawableSpan.ALIGN_CENTER), index, index + iconName.length, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)"
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/java/com/keylesspalace/tusky/util/SpanUtils.kt"
line="122"
column="53"/>
</issue>
<issue
id="InvalidPackage"
message="Invalid package reference in org.bouncycastle:bcprov-jdk15on; not included in Android: `javax.naming.directory`. Referenced from `org.bouncycastle.jce.provider.CrlCache`.">
@ -53,14 +75,14 @@
<issue
id="PrivateResource"
message="Overriding `@layout/exo_player_control_view` which is marked as private in androidx.media3:media3-ui:1.5.1. If deliberate, use tools:override=&quot;true&quot;, otherwise pick a different name.">
message="Overriding `@layout/exo_player_control_view` which is marked as private in androidx.media3:media3-ui:1.6.1. If deliberate, use tools:override=&quot;true&quot;, otherwise pick a different name.">
<location
file="src/main/res/layout/exo_player_control_view.xml"/>
</issue>
<issue
id="PrivateResource"
message="The resource `@color/exo_bottom_bar_background` is marked as private in androidx.media3:media3-ui:1.5.1"
message="The resource `@color/exo_bottom_bar_background` is marked as private in androidx.media3:media3-ui:1.6.1"
errorLine1=" android:background=&quot;@color/exo_bottom_bar_background&quot;"
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
@ -71,7 +93,7 @@
<issue
id="PrivateResource"
message="The resource `@dimen/exo_styled_controls_padding` is marked as private in androidx.media3:media3-ui:1.5.1"
message="The resource `@dimen/exo_styled_controls_padding` is marked as private in androidx.media3:media3-ui:1.6.1"
errorLine1=" android:padding=&quot;@dimen/exo_styled_controls_padding&quot;"
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
@ -82,7 +104,7 @@
<issue
id="PrivateResource"
message="The resource `@layout/exo_player_control_rewind_button` is marked as private in androidx.media3:media3-ui:1.5.1"
message="The resource `@layout/exo_player_control_rewind_button` is marked as private in androidx.media3:media3-ui:1.6.1"
errorLine1=" &lt;include layout=&quot;@layout/exo_player_control_rewind_button&quot; />"
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
@ -93,7 +115,7 @@
<issue
id="PrivateResource"
message="The resource `@layout/exo_player_control_ffwd_button` is marked as private in androidx.media3:media3-ui:1.5.1"
message="The resource `@layout/exo_player_control_ffwd_button` is marked as private in androidx.media3:media3-ui:1.6.1"
errorLine1=" &lt;include layout=&quot;@layout/exo_player_control_ffwd_button&quot; />"
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
@ -104,7 +126,7 @@
<issue
id="PrivateResource"
message="The resource `@dimen/exo_styled_bottom_bar_height` is marked as private in androidx.media3:media3-ui:1.5.1"
message="The resource `@dimen/exo_styled_bottom_bar_height` is marked as private in androidx.media3:media3-ui:1.6.1"
errorLine1=" android:layout_height=&quot;@dimen/exo_styled_bottom_bar_height&quot;"
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
@ -115,7 +137,7 @@
<issue
id="PrivateResource"
message="The resource `@dimen/exo_styled_bottom_bar_margin_top` is marked as private in androidx.media3:media3-ui:1.5.1"
message="The resource `@dimen/exo_styled_bottom_bar_margin_top` is marked as private in androidx.media3:media3-ui:1.6.1"
errorLine1=" android:layout_marginTop=&quot;@dimen/exo_styled_bottom_bar_margin_top&quot;"
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
@ -126,7 +148,7 @@
<issue
id="PrivateResource"
message="The resource `@color/exo_bottom_bar_background` is marked as private in androidx.media3:media3-ui:1.5.1"
message="The resource `@color/exo_bottom_bar_background` is marked as private in androidx.media3:media3-ui:1.6.1"
errorLine1=" android:background=&quot;@color/exo_bottom_bar_background&quot;"
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
@ -137,7 +159,7 @@
<issue
id="PrivateResource"
message="The resource `@dimen/exo_styled_bottom_bar_time_padding` is marked as private in androidx.media3:media3-ui:1.5.1"
message="The resource `@dimen/exo_styled_bottom_bar_time_padding` is marked as private in androidx.media3:media3-ui:1.6.1"
errorLine1=" android:paddingStart=&quot;@dimen/exo_styled_bottom_bar_time_padding&quot;"
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
@ -148,7 +170,7 @@
<issue
id="PrivateResource"
message="The resource `@dimen/exo_styled_bottom_bar_time_padding` is marked as private in androidx.media3:media3-ui:1.5.1"
message="The resource `@dimen/exo_styled_bottom_bar_time_padding` is marked as private in androidx.media3:media3-ui:1.6.1"
errorLine1=" android:paddingEnd=&quot;@dimen/exo_styled_bottom_bar_time_padding&quot;"
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
@ -159,7 +181,7 @@
<issue
id="PrivateResource"
message="The resource `@dimen/exo_styled_bottom_bar_time_padding` is marked as private in androidx.media3:media3-ui:1.5.1"
message="The resource `@dimen/exo_styled_bottom_bar_time_padding` is marked as private in androidx.media3:media3-ui:1.6.1"
errorLine1=" android:paddingLeft=&quot;@dimen/exo_styled_bottom_bar_time_padding&quot;"
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
@ -170,7 +192,7 @@
<issue
id="PrivateResource"
message="The resource `@dimen/exo_styled_bottom_bar_time_padding` is marked as private in androidx.media3:media3-ui:1.5.1"
message="The resource `@dimen/exo_styled_bottom_bar_time_padding` is marked as private in androidx.media3:media3-ui:1.6.1"
errorLine1=" android:paddingRight=&quot;@dimen/exo_styled_bottom_bar_time_padding&quot;"
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
@ -181,7 +203,7 @@
<issue
id="PrivateResource"
message="The resource `@dimen/exo_styled_progress_layout_height` is marked as private in androidx.media3:media3-ui:1.5.1"
message="The resource `@dimen/exo_styled_progress_layout_height` is marked as private in androidx.media3:media3-ui:1.6.1"
errorLine1=" android:layout_height=&quot;@dimen/exo_styled_progress_layout_height&quot;"
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
@ -192,7 +214,7 @@
<issue
id="PrivateResource"
message="The resource `@dimen/exo_styled_progress_margin_bottom` is marked as private in androidx.media3:media3-ui:1.5.1"
message="The resource `@dimen/exo_styled_progress_margin_bottom` is marked as private in androidx.media3:media3-ui:1.6.1"
errorLine1=" android:layout_marginBottom=&quot;@dimen/exo_styled_progress_margin_bottom&quot;/>"
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
@ -203,7 +225,7 @@
<issue
id="PrivateResource"
message="The resource `@dimen/exo_styled_minimal_controls_margin_bottom` is marked as private in androidx.media3:media3-ui:1.5.1"
message="The resource `@dimen/exo_styled_minimal_controls_margin_bottom` is marked as private in androidx.media3:media3-ui:1.6.1"
errorLine1=" android:layout_marginBottom=&quot;@dimen/exo_styled_minimal_controls_margin_bottom&quot;"
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
@ -263,107 +285,19 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/res/values/strings.xml"
line="795"
line="796"
column="5"/>
</issue>
<issue
id="UnusedTranslation"
message="The language `ber (Berber languages)` is present in this project, but not declared in the `localeConfig` resource"
errorLine1=" android:localeConfig=&quot;@xml/locales_config&quot;"
errorLine2=" ~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/AndroidManifest.xml"
line="21"
column="31"/>
</issue>
<issue
id="UnusedTranslation"
message="The language `el (Greek)` is present in this project, but not declared in the `localeConfig` resource"
errorLine1=" android:localeConfig=&quot;@xml/locales_config&quot;"
errorLine2=" ~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/AndroidManifest.xml"
line="21"
column="31"/>
</issue>
<issue
id="UnusedTranslation"
message="The language `fi (Finnish)` is present in this project, but not declared in the `localeConfig` resource"
errorLine1=" android:localeConfig=&quot;@xml/locales_config&quot;"
errorLine2=" ~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/AndroidManifest.xml"
line="21"
column="31"/>
</issue>
<issue
id="UnusedTranslation"
message="The language `fy (Western Frisian)` is present in this project, but not declared in the `localeConfig` resource"
errorLine1=" android:localeConfig=&quot;@xml/locales_config&quot;"
errorLine2=" ~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/AndroidManifest.xml"
line="21"
column="31"/>
</issue>
<issue
id="UnusedTranslation"
message="The language `in (Indonesian)` is present in this project, but not declared in the `localeConfig` resource"
errorLine1=" android:localeConfig=&quot;@xml/locales_config&quot;"
errorLine2=" ~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/AndroidManifest.xml"
line="21"
column="31"/>
</issue>
<issue
id="UnusedTranslation"
message="The language `lv (Latvian)` is present in this project, but not declared in the `localeConfig` resource"
errorLine1=" android:localeConfig=&quot;@xml/locales_config&quot;"
errorLine2=" ~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/AndroidManifest.xml"
line="21"
column="31"/>
</issue>
<issue
id="UnusedTranslation"
message="The language `ml (Malayalam)` is present in this project, but not declared in the `localeConfig` resource"
errorLine1=" android:localeConfig=&quot;@xml/locales_config&quot;"
errorLine2=" ~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/AndroidManifest.xml"
line="21"
column="31"/>
</issue>
<issue
id="UnusedTranslation"
message="The language `si (Sinhala)` is present in this project, but not declared in the `localeConfig` resource"
errorLine1=" android:localeConfig=&quot;@xml/locales_config&quot;"
errorLine2=" ~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/AndroidManifest.xml"
line="21"
column="31"/>
</issue>
<issue
id="UnusedTranslation"
message="The language `sk (Slovak)` is present in this project, but not declared in the `localeConfig` resource"
errorLine1=" android:localeConfig=&quot;@xml/locales_config&quot;"
errorLine2=" ~~~~~~~~~~~~~~~~~~~">
id="PluralsCandidate"
message="Formatting %d followed by words (&quot;people&quot;): This should probably be a plural rather than a string"
errorLine1=" &lt;string name=&quot;notifications_from_people_you_may_know&quot;>Notifications from %1$d people you may know&lt;/string>"
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/AndroidManifest.xml"
line="21"
column="31"/>
file="src/main/res/values/strings.xml"
line="873"
column="5"/>
</issue>
<issue
@ -384,18 +318,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/java/com/keylesspalace/tusky/components/account/AccountActivity.kt"
line="484"
column="9"/>
</issue>
<issue
id="NotifyDataSetChanged"
message="It will always be more efficient to use more specific change events if you can. Rely on `notifyDataSetChanged` as a last resort."
errorLine1=" notifyDataSetChanged()"
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/java/com/keylesspalace/tusky/components/accountlist/adapter/AccountAdapter.kt"
line="78"
line="485"
column="9"/>
</issue>
@ -432,17 +355,6 @@
column="9"/>
</issue>
<issue
id="NotifyDataSetChanged"
message="It will always be more efficient to use more specific change events if you can. Rely on `notifyDataSetChanged` as a last resort."
errorLine1=" notifyDataSetChanged()"
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/java/com/keylesspalace/tusky/components/accountlist/adapter/MutesAdapter.kt"
line="118"
column="9"/>
</issue>
<issue
id="NotifyDataSetChanged"
message="It will always be more efficient to use more specific change events if you can. Rely on `notifyDataSetChanged` as a last resort."
@ -450,7 +362,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/java/com/keylesspalace/tusky/adapter/PollAdapter.kt"
line="62"
line="64"
column="9"/>
</issue>
@ -487,13 +399,6 @@
column="13"/>
</issue>
<issue
id="ConvertToWebp"
message="One or more images in this project can be converted to the WebP format which typically results in smaller file sizes, even for lossless conversion">
<location
file="src/blue/res/mipmap-xxxhdpi/ic_launcher.png"/>
</issue>
<issue
id="NegativeMargin"
message="Margin values should not be negative"
@ -501,7 +406,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/res/layout/item_conversation.xml"
line="252"
line="271"
column="9"/>
</issue>
@ -512,10 +417,21 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/java/com/keylesspalace/tusky/util/ShareShortcutHelper.kt"
line="101"
line="102"
column="13"/>
</issue>
<issue
id="UseKtx"
message="Use the KTX extension function `Context.withStyledAttributes` instead?"
errorLine1=" val a = context.obtainStyledAttributes("
errorLine2=" ^">
<location
file="src/main/java/com/keylesspalace/tusky/view/SliderPreference.kt"
line="100"
column="17"/>
</issue>
<issue
id="ClickableViewAccessibility"
message="Custom view ``ImageView`` has `setOnTouchListener` called on it but does not override `performClick`"
@ -677,30 +593,8 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/java/com/keylesspalace/tusky/adapter/StatusBaseViewHolder.java"
line="1188"
line="1135"
column="42"/>
</issue>
<issue
id="UnknownNullness"
message="Unknown nullability; explicitly declare as `@Nullable` or `@NonNull` to improve Kotlin interoperability; see https://developer.android.com/kotlin/interop#nullability_annotations"
errorLine1=" protected TextView getStatusInfo() {"
errorLine2=" ~~~~~~~~">
<location
file="src/main/java/com/keylesspalace/tusky/adapter/StatusViewHolder.java"
line="149"
column="15"/>
</issue>
<issue
id="UnknownNullness"
message="Should explicitly declare type here since implicit type does not specify nullness"
errorLine1="val hashtagPattern = Pattern.compile(HASHTAG_EXPRESSION, Pattern.CASE_INSENSITIVE or Pattern.MULTILINE)"
errorLine2=" ~~~~~~~~~~~~~~">
<location
file="src/main/java/com/keylesspalace/tusky/util/StringUtils.kt"
line="12"
column="5"/>
</issue>
</issues>

10
app/lint.xml

@ -63,6 +63,9 @@
<!-- This is heavily used by the viewbinding helper -->
<issue id="SyntheticAccessor" severity="ignore" />
<!-- We already have Renovate reminding us of new versions -->
<issue id="AndroidGradlePluginVersion" severity="ignore" />
<!-- Things we would actually question in a code review -->
<issue id="MissingPermission" severity="error" />
<issue id="InvalidPackage" severity="error" />
@ -70,12 +73,7 @@
<issue id="UseCompatTextViewDrawableXml" severity="error" />
<issue id="Recycle" severity="error" />
<issue id="KeyboardInaccessibleWidget" severity="error" />
<!-- these three don't work with Kotlin 2.1 for some reason
https://github.com/tuskyapp/Tusky/pull/4774 -->
<issue id="StateFlowValueCalledInComposition" severity="ignore" />
<issue id="CoroutineCreationDuringComposition" severity="ignore" />
<issue id="FlowOperatorInvokedInComposition" severity="ignore" />
<issue id="UnknownNullness" severity="error" />
<!-- Mark all other lint issues as warnings -->
<issue id="all" severity="warning" />

34
app/proguard-rules.pro vendored

@ -20,29 +20,16 @@
public static final ** CREATOR;
}
# Preserve annotated Javascript interface methods.
-keepclassmembers class * {
@android.webkit.JavascriptInterface <methods>;
}
# The support libraries contains references to newer platform versions.
# Don't warn about those in case this app is linking against an older
# platform version. We know about them, and they are safe.
-dontnote androidx.**
-dontwarn androidx.**
# This class is deprecated, but remains for backward compatibility.
-dontwarn android.util.FloatMath
# These classes are duplicated between android.jar and core-lambda-stubs.jar.
-dontnote java.lang.invoke.**
# TUSKY SPECIFIC OPTIONS
# preserve line numbers for crash reporting
-keepattributes SourceFile,LineNumberTable
-renamesourcefileattribute SourceFile
# Preference fragments can be referenced by name, ensure they remain
# https://github.com/tuskyapp/Tusky/issues/3161
-keep class * extends androidx.preference.PreferenceFragmentCompat
# remove all logging from production apk
-assumenosideeffects class android.util.Log {
public static *** getStackTraceString(...);
@ -66,9 +53,16 @@
static void checkNotNullExpressionValue(java.lang.Object, java.lang.String);
static void checkReturnedValueIsNotNull(java.lang.Object, java.lang.String);
static void checkReturnedValueIsNotNull(java.lang.Object, java.lang.String, java.lang.String);
static void throwUninitializedProperty(java.lang.String);
static void throwUninitializedPropertyAccessException(java.lang.String);
}
# Preference fragments can be referenced by name, ensure they remain
# https://github.com/tuskyapp/Tusky/issues/3161
-keep class * extends androidx.preference.PreferenceFragmentCompat
# there is no need for edit mode in production builds, allow it to be pruned
-assumenosideeffects public class * extends android.view.View {
boolean isInEditMode();
}
-assumevalues public class * extends android.view.View {
boolean isInEditMode() return false;
}
-checkdiscard class com.keylesspalace.tusky.usecase.DeveloperToolsUseCase

1405
app/schemas/com.keylesspalace.tusky.db.AppDatabase/70.json

File diff suppressed because it is too large Load Diff

40
app/src/main/java/com/keylesspalace/tusky/MainActivity.kt

@ -25,7 +25,6 @@ import android.content.pm.PackageManager
import android.graphics.Bitmap
import android.graphics.Color
import android.graphics.drawable.Animatable
import android.graphics.drawable.BitmapDrawable
import android.graphics.drawable.Drawable
import android.net.Uri
import android.os.Build
@ -49,6 +48,7 @@ import androidx.appcompat.content.res.AppCompatResources
import androidx.coordinatorlayout.widget.CoordinatorLayout
import androidx.core.app.ActivityCompat
import androidx.core.content.pm.ShortcutManagerCompat
import androidx.core.graphics.drawable.toDrawable
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.core.view.MenuProvider
import androidx.core.view.ViewCompat
@ -95,11 +95,9 @@ import com.keylesspalace.tusky.pager.MainPagerAdapter
import com.keylesspalace.tusky.settings.PrefKeys
import com.keylesspalace.tusky.usecase.DeveloperToolsUseCase
import com.keylesspalace.tusky.usecase.LogoutUsecase
import com.keylesspalace.tusky.util.ActivityConstants
import com.keylesspalace.tusky.util.emojify
import com.keylesspalace.tusky.util.getParcelableExtraCompat
import com.keylesspalace.tusky.util.hide
import com.keylesspalace.tusky.util.overrideActivityTransitionCompat
import com.keylesspalace.tusky.util.reduceSwipeSensitivity
import com.keylesspalace.tusky.util.show
import com.keylesspalace.tusky.util.startActivityWithSlideInAnimation
@ -128,7 +126,6 @@ import com.mikepenz.materialdrawer.util.updateBadge
import com.mikepenz.materialdrawer.widget.AccountHeaderView
import dagger.hilt.android.AndroidEntryPoint
import dagger.hilt.android.migration.OptionalInject
import de.c1710.filemojicompat_ui.helpers.EMOJI_PREFERENCE
import javax.inject.Inject
import kotlinx.coroutines.launch
@ -164,9 +161,6 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvider {
private var onTabSelectedListener: OnTabSelectedListener? = null
// We need to know if the emoji pack has been changed
private var selectedEmojiPack: String? = null
/** Mediate between binding.viewPager and the chosen tab layout */
private var tabLayoutMediator: TabLayoutMediator? = null
@ -217,16 +211,6 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvider {
viewModel.setupNotifications(this)
}
if (explodeAnimationWasRequested()) {
overrideActivityTransitionCompat(
ActivityConstants.OVERRIDE_TRANSITION_OPEN,
R.anim.explode,
R.anim.activity_open_exit
)
}
selectedEmojiPack = preferences.getString(EMOJI_PREFERENCE, "")
var showNotificationTab = false
// check for savedInstanceState in order to not handle intent events more than once
@ -488,20 +472,6 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvider {
}
}
override fun onResume() {
super.onResume()
val currentEmojiPack = preferences.getString(EMOJI_PREFERENCE, "")
if (currentEmojiPack != selectedEmojiPack) {
Log.d(
TAG,
"onResume: EmojiPack has been changed from %s to %s"
.format(selectedEmojiPack, currentEmojiPack)
)
selectedEmojiPack = currentEmojiPack
recreate()
}
}
override fun dispatchKeyEvent(event: KeyEvent): Boolean {
// Allow software back press to be properly dispatched to drawer layout
val handled = when (event.action) {
@ -1051,7 +1021,7 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvider {
transition: Transition<in Bitmap>?
) {
activeToolbar.navigationIcon = FixedSizeDrawable(
BitmapDrawable(resources, resource),
resource.toDrawable(resources),
navIconSize,
navIconSize
)
@ -1118,15 +1088,9 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvider {
}
}
private fun explodeAnimationWasRequested(): Boolean {
return intent.getBooleanExtra(OPEN_WITH_EXPLODE_ANIMATION, false)
}
override fun getActionButton() = binding.composeButton
companion object {
const val OPEN_WITH_EXPLODE_ANIMATION = "explode"
private const val TAG = "MainActivity" // logging tag
private const val DRAWER_ITEM_ADD_ACCOUNT: Long = -13
private const val DRAWER_ITEM_ANNOUNCEMENTS: Long = 14

3
app/src/main/java/com/keylesspalace/tusky/MainViewModel.kt

@ -15,7 +15,6 @@
package com.keylesspalace.tusky
import android.content.Context
import android.util.Log
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
@ -33,7 +32,6 @@ import com.keylesspalace.tusky.entity.Status
import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.util.ShareShortcutHelper
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
@ -46,7 +44,6 @@ import kotlinx.coroutines.launch
@HiltViewModel
class MainViewModel @Inject constructor(
@ApplicationContext private val context: Context,
private val api: MastodonApi,
private val eventHub: EventHub,
private val accountManager: AccountManager,

14
app/src/main/java/com/keylesspalace/tusky/StatusListActivity.kt

@ -204,7 +204,7 @@ class StatusListActivity : BottomSheetActivity() {
{ filters ->
mutedFilter = filters.firstOrNull { filter ->
// TODO shouldn't this be an exact match (only one keyword; exactly the hashtag)?
filter.context.contains(Filter.Kind.HOME.kind) && filter.title == hashedTag
filter.context.contains(Filter.Kind.HOME) && filter.title == hashedTag
}
updateTagMuteState(mutedFilter != null)
},
@ -250,8 +250,8 @@ class StatusListActivity : BottomSheetActivity() {
mastodonApi.createFilter(
title = "#$tag",
context = listOf(Filter.Kind.HOME.kind),
filterAction = Filter.Action.WARN.action,
context = listOf(Filter.Kind.HOME),
filterAction = Filter.Action.WARN,
expiresIn = FilterExpiration.never
).fold(
{ filter ->
@ -286,7 +286,7 @@ class StatusListActivity : BottomSheetActivity() {
).fold(
{ filter ->
mutedFilterV1 = filter
eventHub.dispatch(FilterUpdatedEvent(filter.context))
eventHub.dispatch(FilterUpdatedEvent(filter.context.map { Filter.Kind.valueOf(it) }))
filterCreateSuccess = true
},
{ throwable2 ->
@ -344,7 +344,7 @@ class StatusListActivity : BottomSheetActivity() {
// This filter exists in multiple contexts, just remove the home context
mastodonApi.updateFilter(
id = filter.id,
context = filter.context.filter { it != Filter.Kind.HOME.kind }
context = filter.context.filterNot { it == Filter.Kind.HOME }
)
} else {
mastodonApi.deleteFilter(filter.id)
@ -356,7 +356,7 @@ class StatusListActivity : BottomSheetActivity() {
mastodonApi.updateFilterV1(
id = filter.id,
phrase = filter.phrase,
context = filter.context.filter { it != Filter.Kind.HOME.kind },
context = filter.context.filterNot { it == Filter.Kind.HOME.kind },
irreversible = null,
wholeWord = null,
expiresIn = FilterExpiration.never
@ -372,7 +372,7 @@ class StatusListActivity : BottomSheetActivity() {
result?.fold(
{
updateTagMuteState(false)
eventHub.dispatch(FilterUpdatedEvent(listOf(Filter.Kind.HOME.kind)))
eventHub.dispatch(FilterUpdatedEvent(listOf(Filter.Kind.HOME)))
mutedFilterV1 = null
mutedFilter = null

39
app/src/main/java/com/keylesspalace/tusky/ViewMediaActivity.kt

@ -38,6 +38,7 @@ import android.widget.Toast
import androidx.activity.result.contract.ActivityResultContracts
import androidx.core.app.ShareCompat
import androidx.core.content.FileProvider
import androidx.core.net.toUri
import androidx.fragment.app.FragmentActivity
import androidx.lifecycle.lifecycleScope
import androidx.viewpager2.adapter.FragmentStateAdapter
@ -63,7 +64,6 @@ import dagger.hilt.android.AndroidEntryPoint
import java.io.File
import java.io.FileOutputStream
import java.io.IOException
import java.util.Locale
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.launch
@ -77,9 +77,6 @@ class ViewMediaActivity :
private val binding by viewBinding(ActivityViewMediaBinding::inflate)
val toolbar: View
get() = binding.toolbar
var isToolbarVisible = true
private set
@ -114,9 +111,6 @@ class ViewMediaActivity :
attachments = intent.getParcelableArrayListExtraCompat(EXTRA_ATTACHMENTS)
val initialPosition = intent.getIntExtra(EXTRA_ATTACHMENT_INDEX, 0)
// Adapter is actually of existential type PageAdapter & SharedElementsTransitionListener
// but it cannot be expressed and if I don't specify type explicitly compilation fails
// (probably a bug in compiler)
val adapter: ViewMediaAdapter = if (attachments != null) {
val realAttachs = attachments!!.map(AttachmentViewData::attachment)
// Setup the view pager.
@ -139,11 +133,10 @@ class ViewMediaActivity :
// Setup the toolbar.
setSupportActionBar(binding.toolbar)
val actionBar = supportActionBar
if (actionBar != null) {
actionBar.setDisplayHomeAsUpEnabled(true)
actionBar.setDisplayShowHomeEnabled(true)
actionBar.title = getPageTitle(initialPosition)
supportActionBar?.run {
setDisplayHomeAsUpEnabled(true)
setDisplayShowHomeEnabled(true)
title = getPageTitle(initialPosition)
}
binding.toolbar.setNavigationOnClickListener { supportFinishAfterTransition() }
binding.toolbar.setOnMenuItemClickListener { item: MenuItem ->
@ -203,14 +196,14 @@ class ViewMediaActivity :
val alpha = if (isToolbarVisible) 1.0f else 0.0f
if (isToolbarVisible) {
// If to be visible, need to make visible immediately and animate alpha
binding.toolbar.alpha = 0.0f
binding.toolbar.visibility = visibility
binding.appBarLayout.alpha = 0.0f
binding.appBarLayout.visibility = visibility
}
binding.toolbar.animate().alpha(alpha)
binding.appBarLayout.animate().alpha(alpha)
.setListener(object : AnimatorListenerAdapter() {
override fun onAnimationEnd(animation: Animator) {
binding.toolbar.visibility = visibility
binding.appBarLayout.visibility = visibility
animation.removeListener(this)
}
})
@ -221,20 +214,20 @@ class ViewMediaActivity :
if (attachments == null) {
return ""
}
return String.format(Locale.getDefault(), "%d/%d", position + 1, attachments?.size)
return "${position + 1}/${attachments?.size}"
}
private fun downloadMedia() {
val url = imageUrl ?: attachments!![binding.viewPager.currentItem].attachment.url
val filename = Uri.parse(url).lastPathSegment
val filename = url.toUri().lastPathSegment
Toast.makeText(
applicationContext,
resources.getString(R.string.download_image, filename),
Toast.LENGTH_SHORT
).show()
val downloadManager = getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager
val request = DownloadManager.Request(Uri.parse(url))
val downloadManager = getSystemService(DOWNLOAD_SERVICE) as DownloadManager
val request = DownloadManager.Request(url.toUri())
request.setDestinationInExternalPublicDir(Environment.DIRECTORY_DOWNLOADS, filename)
downloadManager.enqueue(request)
}
@ -303,7 +296,7 @@ class ViewMediaActivity :
val file = File(directory, getTemporaryMediaFilename("png"))
val result = try {
val bitmap =
Glide.with(applicationContext).asBitmap().load(Uri.parse(url)).submitAsync()
Glide.with(applicationContext).asBitmap().load(url.toUri()).submitAsync()
try {
FileOutputStream(file).use { stream ->
bitmap.compress(Bitmap.CompressFormat.PNG, 100, stream)
@ -332,7 +325,7 @@ class ViewMediaActivity :
}
private fun shareMediaFile(directory: File, url: String) {
val uri = Uri.parse(url)
val uri = url.toUri()
val mimeTypeMap = MimeTypeMap.getSingleton()
val extension = MimeTypeMap.getFileExtensionFromUrl(url)
val mimeType = mimeTypeMap.getMimeTypeFromExtension(extension)
@ -367,7 +360,7 @@ class ViewMediaActivity :
@JvmStatic
fun newIntent(
context: Context?,
context: Context,
attachments: List<AttachmentViewData>,
index: Int
): Intent {

46
app/src/main/java/com/keylesspalace/tusky/adapter/LoadMoreViewHolder.kt

@ -0,0 +1,46 @@
/* Copyright 2021 Tusky Contributors
*
* This file is a part of Tusky.
*
* This program is free software; you can redistribute it and/or modify it under the terms of the
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
* License, or (at your option) any later version.
*
* Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
* Public License for more details.
*
* You should have received a copy of the GNU General Public License along with Tusky; if not,
* see <http://www.gnu.org/licenses>. */
package com.keylesspalace.tusky.adapter
import androidx.recyclerview.widget.RecyclerView
import com.keylesspalace.tusky.databinding.ItemLoadMoreBinding
import com.keylesspalace.tusky.interfaces.StatusActionListener
import com.keylesspalace.tusky.util.hide
import com.keylesspalace.tusky.util.visible
/**
* Placeholder for missing parts in timelines.
*
* Displays a "Load more" button to load the gap, or a
* circular progress bar if the missing page is being loaded.
*/
class LoadMoreViewHolder(
private val binding: ItemLoadMoreBinding,
listener: StatusActionListener
) : RecyclerView.ViewHolder(binding.root) {
init {
binding.loadMoreButton.setOnClickListener {
binding.loadMoreButton.hide()
binding.loadMoreProgressBar.show()
listener.onLoadMore(bindingAdapterPosition)
}
}
fun setup(loading: Boolean) {
binding.loadMoreButton.visible(!loading)
binding.loadMoreProgressBar.visible(loading)
}
}

4
app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationLoadStateAdapter.kt → app/src/main/java/com/keylesspalace/tusky/adapter/LoadStateFooterAdapter.kt

@ -13,7 +13,7 @@
* You should have received a copy of the GNU General Public License along with Tusky; if not,
* see <http://www.gnu.org/licenses>. */
package com.keylesspalace.tusky.components.conversation
package com.keylesspalace.tusky.adapter
import android.view.LayoutInflater
import android.view.ViewGroup
@ -23,7 +23,7 @@ import com.keylesspalace.tusky.databinding.ItemNetworkStateBinding
import com.keylesspalace.tusky.util.BindingHolder
import com.keylesspalace.tusky.util.visible
class ConversationLoadStateAdapter(
class LoadStateFooterAdapter(
private val retryCallback: () -> Unit
) : LoadStateAdapter<BindingHolder<ItemNetworkStateBinding>>() {

45
app/src/main/java/com/keylesspalace/tusky/adapter/PlaceholderViewHolder.kt

@ -1,4 +1,4 @@
/* Copyright 2021 Tusky Contributors
/* Copyright 2025 Tusky Contributors
*
* This file is a part of Tusky.
*
@ -12,35 +12,40 @@
*
* You should have received a copy of the GNU General Public License along with Tusky; if not,
* see <http://www.gnu.org/licenses>. */
package com.keylesspalace.tusky.adapter
import android.view.ViewGroup
import androidx.core.view.updateLayoutParams
import androidx.core.view.updatePaddingRelative
import androidx.recyclerview.widget.RecyclerView
import com.keylesspalace.tusky.databinding.ItemStatusPlaceholderBinding
import com.keylesspalace.tusky.interfaces.StatusActionListener
import com.keylesspalace.tusky.util.hide
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.databinding.ItemPlaceholderBinding
import com.keylesspalace.tusky.util.visible
/**
* Placeholder for missing parts in timelines.
*
* Displays a "Load more" button to load the gap, or a
* circular progress bar if the missing page is being loaded.
*/
class PlaceholderViewHolder(
private val binding: ItemStatusPlaceholderBinding,
listener: StatusActionListener
binding: ItemPlaceholderBinding,
mode: Mode,
) : RecyclerView.ViewHolder(binding.root) {
init {
binding.loadMoreButton.setOnClickListener {
binding.loadMoreButton.hide()
binding.loadMoreProgressBar.show()
listener.onLoadMore(bindingAdapterPosition)
val res = binding.root.context.resources
binding.topPlaceholder.visible(mode != Mode.STATUS)
binding.reblogButtonPlaceholder.visible(mode != Mode.CONVERSATION)
if (mode == Mode.NOTIFICATION) {
binding.topPlaceholder.updatePaddingRelative(
start = res.getDimensionPixelSize(R.dimen.status_info_padding_large)
)
}
if (mode == Mode.CONVERSATION) {
binding.moreButtonPlaceHolder.updateLayoutParams<ViewGroup.MarginLayoutParams> {
marginEnd = res.getDimensionPixelSize(R.dimen.conversation_placeholder_more_button_inset)
}
}
}
fun setup(loading: Boolean) {
binding.loadMoreButton.visible(!loading)
binding.loadMoreProgressBar.visible(loading)
enum class Mode {
STATUS,
NOTIFICATION,
CONVERSATION
}
}

48
app/src/main/java/com/keylesspalace/tusky/adapter/PollAdapter.kt

@ -15,10 +15,12 @@
package com.keylesspalace.tusky.adapter
import android.content.res.ColorStateList
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.color.MaterialColors
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.databinding.ItemPollBinding
import com.keylesspalace.tusky.entity.Emoji
@ -91,24 +93,34 @@ class PollAdapter : RecyclerView.Adapter<BindingHolder<ItemPollBinding>>() {
radioButton.isEnabled = enabled
checkBox.isEnabled = enabled
when (mode) {
RESULT -> {
val percent = calculatePercent(option.votesCount, votersCount, voteCount)
resultTextView.text = buildDescription(option.title, percent, option.voted, resultTextView.context)
.emojify(emojis, resultTextView, animateEmojis)
val level = percent * 100
val optionColor = if (option.voted) {
R.color.colorBackgroundHighlight
} else {
R.color.colorBackgroundAccent
}
if (mode == RESULT) {
val percent = calculatePercent(option.votesCount, votersCount, voteCount)
resultTextView.text = buildDescription(option.title, percent, option.voted, resultTextView.context, resultTextView)
.emojify(emojis, resultTextView, animateEmojis)
val level = percent * 100
val optionColor = if (option.voted) {
R.color.colorBackgroundHighlight
} else {
R.color.colorBackgroundAccent
}
resultTextView.background.level = level
resultTextView.background.setTint(resultTextView.context.getColor(optionColor))
resultTextView.setOnClickListener(resultClickListener)
holder.binding.pollLayout.setBackgroundResource(R.drawable.poll_option_background)
holder.binding.pollLayout.background.level = level
holder.binding.pollLayout.background.setTint(resultTextView.context.getColor(optionColor))
holder.binding.root.strokeColor = holder.binding.root.context.getColor(optionColor)
resultTextView.setOnClickListener(resultClickListener)
} else {
holder.binding.pollLayout.background = null
if (option.selected) {
holder.binding.root.setCardBackgroundColor(ColorStateList.valueOf(MaterialColors.getColor(holder.binding.root, com.google.android.material.R.attr.colorSurface)))
holder.binding.root.strokeColor = MaterialColors.getColor(holder.binding.root, com.google.android.material.R.attr.colorSurface)
} else {
holder.binding.root.setCardBackgroundColor(ColorStateList.valueOf(MaterialColors.getColor(holder.binding.root, android.R.attr.colorBackground)))
holder.binding.root.strokeColor = MaterialColors.getColor(holder.binding.root, R.attr.colorBackgroundAccent)
}
SINGLE -> {
if (mode == SINGLE) {
radioButton.text = option.title.emojify(emojis, radioButton, animateEmojis)
radioButton.isChecked = option.selected
radioButton.setOnClickListener {
@ -117,12 +129,12 @@ class PollAdapter : RecyclerView.Adapter<BindingHolder<ItemPollBinding>>() {
notifyItemChanged(index)
}
}
}
MULTIPLE -> {
} else { // mode == MULTIPLE
checkBox.text = option.title.emojify(emojis, checkBox, animateEmojis)
checkBox.isChecked = option.selected
checkBox.setOnCheckedChangeListener { _, isChecked ->
pollOptions[holder.bindingAdapterPosition].selected = isChecked
notifyItemChanged(holder.bindingAdapterPosition)
}
}
}

120
app/src/main/java/com/keylesspalace/tusky/adapter/StatusBaseViewHolder.java

@ -11,7 +11,6 @@ import android.text.Spanned;
import android.text.TextUtils;
import android.text.format.DateUtils;
import android.view.Gravity;
import android.view.Menu;
import android.view.View;
import android.view.ViewGroup;
import android.widget.Button;
@ -24,7 +23,6 @@ import androidx.annotation.DrawableRes;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.appcompat.content.res.AppCompatResources;
import androidx.appcompat.widget.PopupMenu;
import androidx.appcompat.widget.TooltipCompat;
import androidx.constraintlayout.widget.ConstraintLayout;
import androidx.core.text.HtmlCompat;
@ -45,9 +43,10 @@ import com.keylesspalace.tusky.ViewMediaActivity;
import com.keylesspalace.tusky.entity.Attachment;
import com.keylesspalace.tusky.entity.Attachment.Focus;
import com.keylesspalace.tusky.entity.Attachment.MetaData;
import com.keylesspalace.tusky.entity.PreviewCard;
import com.keylesspalace.tusky.entity.Emoji;
import com.keylesspalace.tusky.entity.Filter;
import com.keylesspalace.tusky.entity.HashTag;
import com.keylesspalace.tusky.entity.PreviewCard;
import com.keylesspalace.tusky.entity.Status;
import com.keylesspalace.tusky.entity.TimelineAccount;
import com.keylesspalace.tusky.entity.Translation;
@ -102,6 +101,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
private final TextView sensitiveMediaWarning;
private final View sensitiveMediaShow;
protected final TextView[] mediaLabels;
protected final MaterialCardView[] mediaLabelContainers;
protected final CharSequence[] mediaDescriptions;
private final MaterialButton contentWarningButton;
private final ImageView avatarInset;
@ -166,6 +166,12 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
itemView.findViewById(R.id.status_media_label_2),
itemView.findViewById(R.id.status_media_label_3)
};
mediaLabelContainers = new MaterialCardView[]{
itemView.findViewById(R.id.status_media_label_container_0),
itemView.findViewById(R.id.status_media_label_container_1),
itemView.findViewById(R.id.status_media_label_container_2),
itemView.findViewById(R.id.status_media_label_container_3)
};
mediaDescriptions = new CharSequence[mediaLabels.length];
contentWarningDescription = itemView.findViewById(R.id.status_content_warning_description);
contentWarningButton = itemView.findViewById(R.id.status_content_warning_button);
@ -277,7 +283,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
this.setTextVisible(sensitive, expanded, status, statusDisplayOptions, listener);
setupCard(status, expanded, statusDisplayOptions.cardViewMode(), statusDisplayOptions, listener);
setupCard(status, expanded, !status.isShowingContent(), statusDisplayOptions.cardViewMode(), statusDisplayOptions, listener);
}
private void setTextVisible(boolean sensitive,
@ -523,7 +529,8 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
boolean sensitive,
final @NonNull StatusActionListener listener,
boolean showingContent,
boolean useBlurhash
boolean useBlurhash,
final @NonNull Filter filter
) {
mediaPreview.setVisibility(View.VISIBLE);
@ -559,7 +566,9 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
final CharSequence formattedDescription = AttachmentHelper.getFormattedDescription(attachment, imageView.getContext());
setAttachmentClickListener(imageView, listener, i, formattedDescription, true);
if (sensitive) {
if (filter != null) {
sensitiveMediaWarning.setText(sensitiveMediaWarning.getContext().getString(R.string.status_filter_placeholder_label_format, filter.getTitle()));
} else if (sensitive) {
sensitiveMediaWarning.setText(R.string.post_sensitive_media_title);
} else {
sensitiveMediaWarning.setText(R.string.post_media_hidden_title);
@ -617,7 +626,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
TextView mediaLabel = mediaLabels[i];
if (i < attachments.size()) {
Attachment attachment = attachments.get(i);
mediaLabel.setVisibility(View.VISIBLE);
mediaLabelContainers[i].setVisibility(View.VISIBLE);
mediaDescriptions[i] = AttachmentHelper.getFormattedDescription(attachment, context);
updateMediaLabel(i, sensitive, showingContent);
@ -627,7 +636,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
setAttachmentClickListener(mediaLabel, listener, i, mediaDescriptions[i], false);
} else {
mediaLabel.setVisibility(View.GONE);
mediaLabelContainers[i].setVisibility(View.GONE);
}
}
}
@ -668,40 +677,24 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
}
});
if (reblogButton != null) {
reblogButton.setEventListener((button, buttonState) -> {
// return true to play animation
int position = getBindingAdapterPosition();
if (position != RecyclerView.NO_POSITION) {
if (statusDisplayOptions.confirmReblogs()) {
showConfirmReblog(listener, buttonState, position);
return false;
} else {
listener.onReblog(!buttonState, position, Status.Visibility.PUBLIC);
return true;
}
} else {
return false;
listener.onReblog(!buttonState, position, null, button);
}
return false;
});
}
favouriteButton.setEventListener((button, buttonState) -> {
// return true to play animation
int position = getBindingAdapterPosition();
if (position != RecyclerView.NO_POSITION) {
if (statusDisplayOptions.confirmFavourites()) {
showConfirmFavourite(listener, buttonState, position);
return false;
} else {
listener.onFavourite(!buttonState, position);
return true;
}
} else {
return true;
listener.onFavourite(!buttonState, position, button);
}
return false;
});
bookmarkButton.setEventListener((button, buttonState) -> {
@ -732,62 +725,6 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
itemView.setOnClickListener(viewThreadListener);
}
private void showConfirmReblog(StatusActionListener listener,
boolean buttonState,
int position) {
PopupMenu popup = new PopupMenu(itemView.getContext(), reblogButton);
popup.inflate(R.menu.status_reblog);
Menu menu = popup.getMenu();
if (buttonState) {
menu.setGroupVisible(R.id.menu_action_reblog_group, false);
} else {
menu.findItem(R.id.menu_action_unreblog).setVisible(false);
}
popup.setOnMenuItemClickListener(item -> {
if (buttonState) {
listener.onReblog(false, position, Status.Visibility.PUBLIC);
} else {
Status.Visibility visibility;
if (item.getItemId() == R.id.menu_action_reblog_public) {
visibility = Status.Visibility.PUBLIC;
} else if (item.getItemId() == R.id.menu_action_reblog_unlisted) {
visibility = Status.Visibility.UNLISTED;
} else if (item.getItemId() == R.id.menu_action_reblog_private) {
visibility = Status.Visibility.PRIVATE;
} else {
visibility = Status.Visibility.PUBLIC;
}
listener.onReblog(true, position, visibility);
reblogButton.playAnimation();
reblogButton.setChecked(true);
}
return true;
});
popup.show();
}
private void showConfirmFavourite(StatusActionListener listener,
boolean buttonState,
int position) {
PopupMenu popup = new PopupMenu(itemView.getContext(), favouriteButton);
popup.inflate(R.menu.status_favourite);
Menu menu = popup.getMenu();
if (buttonState) {
menu.findItem(R.id.menu_action_favourite).setVisible(false);
} else {
menu.findItem(R.id.menu_action_unfavourite).setVisible(false);
}
popup.setOnMenuItemClickListener(item -> {
listener.onFavourite(!buttonState, position);
if (!buttonState) {
favouriteButton.playAnimation();
favouriteButton.setChecked(true);
}
return true;
});
popup.show();
}
public void setupWithStatus(@NonNull StatusViewData.Concrete status,
@NonNull final StatusActionListener listener,
@NonNull StatusDisplayOptions statusDisplayOptions,
@ -812,14 +749,14 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
} else if (statusDisplayOptions.mediaPreviewEnabled() && hasPreviewableAttachment(attachments)) {
mediaContainer.setVisibility(View.VISIBLE);
setMediaPreviews(attachments, sensitive, listener, status.isShowingContent(), statusDisplayOptions.useBlurhash());
setMediaPreviews(attachments, sensitive, listener, status.isShowingContent(), statusDisplayOptions.useBlurhash(), status.getFilter());
if (attachments.isEmpty()) {
hideSensitiveMediaWarning();
}
// Hide the unused label.
for (TextView mediaLabel : mediaLabels) {
mediaLabel.setVisibility(View.GONE);
for (MaterialCardView mediaLabelContainer : mediaLabelContainers) {
mediaLabelContainer.setVisibility(View.GONE);
}
} else {
mediaContainer.setVisibility(View.VISIBLE);
@ -830,7 +767,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
hideSensitiveMediaWarning();
}
setupCard(status, status.isExpanded(), statusDisplayOptions.cardViewMode(), statusDisplayOptions, listener);
setupCard(status, status.isExpanded(), !status.isShowingContent(), statusDisplayOptions.cardViewMode(), statusDisplayOptions, listener);
setupButtons(listener, actionable.getAccount().getId(), status.getContent().toString(),
statusDisplayOptions);
@ -855,7 +792,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
setMetaData(status, statusDisplayOptions, listener);
if (status.getStatus().getCard() != null && status.getStatus().getCard().getPublishedAt() != null) {
// there is a preview card showing the published time, we need to refresh it as well
setupCard(status, status.isExpanded(), statusDisplayOptions.cardViewMode(), statusDisplayOptions, listener);
setupCard(status, status.isExpanded(), !status.isShowingContent(), statusDisplayOptions.cardViewMode(), statusDisplayOptions, listener);
}
break;
}
@ -1030,7 +967,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
for (int i = 0; i < args.length; i++) {
if (i < options.size()) {
int percent = PollViewDataKt.calculatePercent(options.get(i).getVotesCount(), poll.getVotersCount(), poll.getVotesCount());
args[i] = buildDescription(options.get(i).getTitle(), percent, options.get(i).getVoted(), context);
args[i] = buildDescription(options.get(i).getTitle(), percent, options.get(i).getVoted(), context, null);
} else {
args[i] = "";
}
@ -1157,6 +1094,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
protected void setupCard(
final @NonNull StatusViewData.Concrete status,
boolean expanded,
boolean blurMedia,
final @NonNull CardViewMode cardViewMode,
final @NonNull StatusDisplayOptions statusDisplayOptions,
final @NonNull StatusActionListener listener
@ -1236,7 +1174,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
// Statuses from other activitypub sources can be marked sensitive even if there's no media,
// so let's blur the preview in that case
// If media previews are disabled, show placeholder for cards as well
if (statusDisplayOptions.mediaPreviewEnabled() && !actionable.getSensitive() && !TextUtils.isEmpty(card.getImage())) {
if (statusDisplayOptions.mediaPreviewEnabled() && !blurMedia && !actionable.getSensitive() && !TextUtils.isEmpty(card.getImage())) {
int radius = context.getResources().getDimensionPixelSize(R.dimen.inner_card_radius);
ShapeAppearanceModel.Builder cardImageShape = ShapeAppearanceModel.builder();

2
app/src/main/java/com/keylesspalace/tusky/adapter/StatusDetailedViewHolder.java

@ -149,7 +149,7 @@ public class StatusDetailedViewHolder extends StatusBaseViewHolder {
status;
super.setupWithStatus(uncollapsedStatus, listener, statusDisplayOptions, payloads, showStatusInfo);
setupCard(uncollapsedStatus, status.isExpanded(), CardViewMode.FULL_WIDTH, statusDisplayOptions, listener); // Always show card for detailed status
setupCard(uncollapsedStatus, status.isExpanded(), !status.isShowingContent(), CardViewMode.FULL_WIDTH, statusDisplayOptions, listener); // Always show card for detailed status
if (payloads.isEmpty()) {
Status actionable = uncollapsedStatus.getActionable();

4
app/src/main/java/com/keylesspalace/tusky/adapter/StatusViewHolder.java

@ -69,7 +69,7 @@ public class StatusViewHolder extends StatusBaseViewHolder {
setupCollapsedState(sensitive, expanded, status, listener);
if (!showStatusInfo || status.getFilterAction() == Filter.Action.WARN) {
if (!showStatusInfo || (status.getFilter() != null && status.getFilter().getAction() == Filter.Action.WARN)) {
hideStatusInfo();
} else {
Status rebloggingStatus = status.getRebloggingStatus();
@ -146,7 +146,7 @@ public class StatusViewHolder extends StatusBaseViewHolder {
statusInfo.setVisibility(View.GONE);
}
protected TextView getStatusInfo() {
protected @NonNull TextView getStatusInfo() {
return statusInfo;
}

3
app/src/main/java/com/keylesspalace/tusky/appstore/Events.kt

@ -1,6 +1,7 @@
package com.keylesspalace.tusky.appstore
import com.keylesspalace.tusky.entity.Account
import com.keylesspalace.tusky.entity.Filter
import com.keylesspalace.tusky.entity.Notification
import com.keylesspalace.tusky.entity.Poll
import com.keylesspalace.tusky.entity.Status
@ -18,7 +19,7 @@ data class PollVoteEvent(val statusId: String, val poll: Poll) : Event
data class PollShowResultsEvent(val statusId: String) : Event
data class DomainMuteEvent(val instance: String) : Event
data class AnnouncementReadEvent(val announcementId: String) : Event
data class FilterUpdatedEvent(val filterContext: List<String>) : Event
data class FilterUpdatedEvent(val filterContext: List<Filter.Kind>) : Event
data class NewNotificationsEvent(
val accountId: String,
val notifications: List<Notification>

7
app/src/main/java/com/keylesspalace/tusky/components/account/media/AccountMediaFragment.kt

@ -35,8 +35,10 @@ import com.keylesspalace.tusky.ViewMediaActivity
import com.keylesspalace.tusky.databinding.FragmentTimelineBinding
import com.keylesspalace.tusky.db.AccountManager
import com.keylesspalace.tusky.entity.Attachment
import com.keylesspalace.tusky.interfaces.ActionButtonActivity
import com.keylesspalace.tusky.interfaces.RefreshableFragment
import com.keylesspalace.tusky.settings.PrefKeys
import com.keylesspalace.tusky.util.ensureBottomPadding
import com.keylesspalace.tusky.util.hide
import com.keylesspalace.tusky.util.openLink
import com.keylesspalace.tusky.util.show
@ -78,6 +80,9 @@ class AccountMediaFragment :
val useBlurhash = preferences.getBoolean(PrefKeys.USE_BLURHASH, true)
val hasFab = (activity as? ActionButtonActivity?)?.actionButton != null
binding.recyclerView.ensureBottomPadding(fab = hasFab)
val adapter = AccountMediaGridAdapter(
useBlurhash = useBlurhash,
context = view.context,
@ -177,7 +182,7 @@ class AccountMediaFragment :
Attachment.Type.VIDEO,
Attachment.Type.AUDIO -> {
val intent = ViewMediaActivity.newIntent(
context,
view.context,
attachmentsFromSameStatus,
currentIndex
)

7
app/src/main/java/com/keylesspalace/tusky/components/accountlist/AccountListActivity.kt

@ -61,8 +61,11 @@ class AccountListActivity : BottomSheetActivity() {
setDisplayShowHomeEnabled(true)
}
supportFragmentManager.commit {
replace(R.id.fragment_container, AccountListFragment.newInstance(type, id))
if (supportFragmentManager.findFragmentById(R.id.fragment_container) == null) {
supportFragmentManager.commit {
val fragment = AccountListFragment.newInstance(type, id)
replace(R.id.fragment_container, fragment)
}
}
}

359
app/src/main/java/com/keylesspalace/tusky/components/accountlist/AccountListFragment.kt

@ -20,21 +20,22 @@ import android.os.Bundle
import android.util.Log
import android.view.View
import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels
import androidx.lifecycle.lifecycleScope
import androidx.paging.LoadState
import androidx.recyclerview.widget.ConcatAdapter
import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import androidx.recyclerview.widget.SimpleItemAnimator
import at.connyduck.calladapter.networkresult.fold
import com.google.android.material.snackbar.BaseTransientBottomBar
import com.google.android.material.snackbar.Snackbar
import com.keylesspalace.tusky.BottomSheetActivity
import com.keylesspalace.tusky.PostLookupFallbackBehavior
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.StatusListActivity
import com.keylesspalace.tusky.adapter.LoadStateFooterAdapter
import com.keylesspalace.tusky.components.account.AccountActivity
import com.keylesspalace.tusky.components.accountlist.AccountListActivity.Type
import com.keylesspalace.tusky.components.accountlist.adapter.AccountAdapter
import com.keylesspalace.tusky.components.accountlist.adapter.BlocksAdapter
import com.keylesspalace.tusky.components.accountlist.adapter.FollowAdapter
import com.keylesspalace.tusky.components.accountlist.adapter.FollowRequestsAdapter
@ -42,25 +43,21 @@ import com.keylesspalace.tusky.components.accountlist.adapter.FollowRequestsHead
import com.keylesspalace.tusky.components.accountlist.adapter.MutesAdapter
import com.keylesspalace.tusky.databinding.FragmentAccountListBinding
import com.keylesspalace.tusky.db.AccountManager
import com.keylesspalace.tusky.entity.Relationship
import com.keylesspalace.tusky.entity.TimelineAccount
import com.keylesspalace.tusky.interfaces.AccountActionListener
import com.keylesspalace.tusky.interfaces.LinkListener
import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.settings.PrefKeys
import com.keylesspalace.tusky.util.HttpHeaderLink
import com.keylesspalace.tusky.util.ensureBottomPadding
import com.keylesspalace.tusky.util.getSerializableCompat
import com.keylesspalace.tusky.util.hide
import com.keylesspalace.tusky.util.show
import com.keylesspalace.tusky.util.startActivityWithSlideInAnimation
import com.keylesspalace.tusky.util.viewBinding
import com.keylesspalace.tusky.view.EndlessOnScrollListener
import com.keylesspalace.tusky.util.visible
import dagger.hilt.android.AndroidEntryPoint
import dagger.hilt.android.lifecycle.withCreationCallback
import javax.inject.Inject
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import retrofit2.Response
@AndroidEntryPoint
class AccountListFragment :
@ -68,9 +65,6 @@ class AccountListFragment :
AccountActionListener,
LinkListener {
@Inject
lateinit var api: MastodonApi
@Inject
lateinit var accountManager: AccountManager
@ -79,13 +73,20 @@ class AccountListFragment :
private val binding by viewBinding(FragmentAccountListBinding::bind)
private val viewModel: AccountListViewModel by viewModels(
extrasProducer = {
defaultViewModelCreationExtras.withCreationCallback<AccountListViewModel.Factory> { factory ->
factory.create(
type = requireArguments().getSerializableCompat(ARG_TYPE)!!,
accountId = requireArguments().getString(ARG_ID)
)
}
}
)
private lateinit var type: Type
private var id: String? = null
private var adapter: AccountAdapter<*>? = null
private var fetching = false
private var bottomId: String? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
type = requireArguments().getSerializableCompat(ARG_TYPE)!!
@ -123,31 +124,59 @@ class AccountListFragment :
}
else -> FollowAdapter(this, animateAvatar, animateEmojis, showBotOverlay)
}
this.adapter = adapter
if (binding.recyclerView.adapter == null) {
binding.recyclerView.adapter = adapter
binding.recyclerView.adapter = adapter.withLoadStateFooter(LoadStateFooterAdapter(adapter::retry))
binding.swipeRefreshLayout.setOnRefreshListener { adapter.refresh() }
lifecycleScope.launch {
viewModel.accountPager.collectLatest { pagingData ->
adapter.submitData(pagingData)
}
}
val scrollListener = object : EndlessOnScrollListener(layoutManager) {
override fun onLoadMore(totalItemsCount: Int, view: RecyclerView) {
if (bottomId == null) {
return
lifecycleScope.launch {
viewModel.uiEvents.collect { event ->
val message = if (event.throwable != null) {
getString(event.message, event.user, event.throwable.message ?: getString(R.string.error_generic))
} else {
getString(event.message, event.user)
}
fetchAccounts(adapter, bottomId)
Snackbar.make(binding.recyclerView, message, Snackbar.LENGTH_LONG)
.setAction(event.actionText, event.action)
.addCallback(object : BaseTransientBottomBar.BaseCallback<Snackbar>() {
override fun onDismissed(transientBottomBar: Snackbar, eventType: Int) {
viewModel.consumeEvent(event)
}
})
.show()
}
}
binding.recyclerView.addOnScrollListener(scrollListener)
binding.swipeRefreshLayout.setOnRefreshListener { fetchAccounts(adapter) }
adapter.addLoadStateListener { loadState ->
binding.progressBar.visible(
loadState.refresh == LoadState.Loading && adapter.itemCount == 0
)
fetchAccounts(adapter)
}
if (loadState.refresh != LoadState.Loading) {
binding.swipeRefreshLayout.isRefreshing = false
}
override fun onDestroyView() {
// Clear the adapter to prevent leaking the View
adapter = null
super.onDestroyView()
if (loadState.refresh is LoadState.Error) {
binding.recyclerView.hide()
binding.messageView.show()
val errorState = loadState.refresh as LoadState.Error
binding.messageView.setup(errorState.error) { adapter.retry() }
Log.w(TAG, "error loading accounts", errorState.error)
} else if (loadState.refresh is LoadState.NotLoading && adapter.itemCount == 0) {
binding.recyclerView.hide()
binding.messageView.show()
binding.messageView.setup(R.drawable.elephant_friend_empty, R.string.message_empty)
} else {
binding.recyclerView.show()
binding.messageView.hide()
}
}
}
override fun onViewTag(tag: String) {
@ -165,275 +194,29 @@ class AccountListFragment :
}
override fun onMute(mute: Boolean, id: String, position: Int, notifications: Boolean) {
viewLifecycleOwner.lifecycleScope.launch {
try {
if (!mute) {
api.unmuteAccount(id)
} else {
api.muteAccount(id, notifications)
}
onMuteSuccess(mute, id, position, notifications)
} catch (_: Throwable) {
onMuteFailure(mute, id, notifications)
}
}
}
private fun onMuteSuccess(muted: Boolean, id: String, position: Int, notifications: Boolean) {
val mutesAdapter = adapter as MutesAdapter
if (muted) {
mutesAdapter.updateMutingNotifications(id, notifications, position)
return
}
val unmutedUser = mutesAdapter.removeItem(position)
if (unmutedUser != null) {
Snackbar.make(binding.recyclerView, R.string.confirmation_unmuted, Snackbar.LENGTH_LONG)
.setAction(R.string.action_undo) {
mutesAdapter.addItem(unmutedUser, position)
onMute(true, id, position, notifications)
}
.show()
}
}
private fun onMuteFailure(mute: Boolean, accountId: String, notifications: Boolean) {
val verb = if (mute) {
if (notifications) {
"mute (notifications = true)"
} else {
"mute (notifications = false)"
}
if (mute) {
viewModel.mute(id, notifications)
} else {
"unmute"
viewModel.unmute(id)
}
Log.e(TAG, "Failed to $verb account id $accountId")
}
override fun onBlock(block: Boolean, id: String, position: Int) {
viewLifecycleOwner.lifecycleScope.launch {
try {
if (!block) {
api.unblockAccount(id)
} else {
api.blockAccount(id)
}
onBlockSuccess(block, id, position)
} catch (_: Throwable) {
onBlockFailure(block, id)
}
}
}
private fun onBlockSuccess(blocked: Boolean, id: String, position: Int) {
if (blocked) {
return
}
val blocksAdapter = adapter as BlocksAdapter
val unblockedUser = blocksAdapter.removeItem(position)
if (unblockedUser != null) {
Snackbar.make(
binding.recyclerView,
R.string.confirmation_unblocked,
Snackbar.LENGTH_LONG
)
.setAction(R.string.action_undo) {
blocksAdapter.addItem(unblockedUser, position)
onBlock(true, id, position)
}
.show()
}
viewModel.unblock(id)
}
private fun onBlockFailure(block: Boolean, accountId: String) {
val verb = if (block) {
"block"
} else {
"unblock"
}
Log.e(TAG, "Failed to $verb account accountId $accountId")
}
override fun onRespondToFollowRequest(accept: Boolean, id: String, position: Int) {
viewLifecycleOwner.lifecycleScope.launch {
if (accept) {
api.authorizeFollowRequest(id)
} else {
api.rejectFollowRequest(id)
}.fold(
onSuccess = {
onRespondToFollowRequestSuccess(position)
},
onFailure = { throwable ->
val verb = if (accept) {
"accept"
} else {
"reject"
}
Log.e(TAG, "Failed to $verb account id $id.", throwable)
}
)
}
}
private fun onRespondToFollowRequestSuccess(position: Int) {
val followRequestsAdapter = adapter as FollowRequestsAdapter
followRequestsAdapter.removeItem(position)
}
private suspend fun getFetchCallByListType(fromId: String?): Response<List<TimelineAccount>> {
return when (type) {
Type.FOLLOWS -> {
val accountId = requireId(type, id)
api.accountFollowing(accountId, fromId)
}
Type.FOLLOWERS -> {
val accountId = requireId(type, id)
api.accountFollowers(accountId, fromId)
}
Type.BLOCKS -> api.blocks(fromId)
Type.MUTES -> api.mutes(fromId)
Type.FOLLOW_REQUESTS -> api.followRequests(fromId)
Type.REBLOGGED -> {
val statusId = requireId(type, id)
api.statusRebloggedBy(statusId, fromId)
}
Type.FAVOURITED -> {
val statusId = requireId(type, id)
api.statusFavouritedBy(statusId, fromId)
}
}
}
private fun requireId(type: Type, id: String?): String {
return requireNotNull(id) { "id must not be null for type " + type.name }
}
private fun fetchAccounts(adapter: AccountAdapter<*>, fromId: String? = null) {
if (fetching) {
return
}
fetching = true
binding.swipeRefreshLayout.isRefreshing = true
if (fromId != null) {
binding.recyclerView.post { adapter.setBottomLoading(true) }
}
viewLifecycleOwner.lifecycleScope.launch {
try {
val response = getFetchCallByListType(fromId)
if (!response.isSuccessful) {
onFetchAccountsFailure(adapter, Exception(response.message()))
return@launch
}
val accountList = response.body()
if (accountList == null) {
onFetchAccountsFailure(adapter, Exception(response.message()))
return@launch
}
val linkHeader = response.headers()["Link"]
onFetchAccountsSuccess(adapter, accountList, linkHeader)
} catch (exception: Exception) {
if (exception is CancellationException) {
// Scope is cancelled, probably because the fragment is destroyed.
// We must not touch any views anymore, so rethrow the exception.
// (CancellationException in a cancelled scope is normal and will be ignored)
throw exception
}
onFetchAccountsFailure(adapter, exception)
}
}
}
private fun onFetchAccountsSuccess(
adapter: AccountAdapter<*>,
accounts: List<TimelineAccount>,
linkHeader: String?
) {
adapter.setBottomLoading(false)
binding.swipeRefreshLayout.isRefreshing = false
val links = HttpHeaderLink.parse(linkHeader)
val next = HttpHeaderLink.findByRelationType(links, "next")
val fromId = next?.uri?.getQueryParameter("max_id")
if (adapter.itemCount > 0) {
adapter.addItems(accounts)
} else {
adapter.update(accounts)
}
if (adapter is MutesAdapter) {
fetchRelationships(adapter, accounts.map { it.id })
}
bottomId = fromId
fetching = false
if (adapter.itemCount == 0) {
binding.messageView.show()
binding.messageView.setup(
R.drawable.elephant_friend_empty,
R.string.message_empty,
null
)
} else {
binding.messageView.hide()
}
}
private fun fetchRelationships(mutesAdapter: MutesAdapter, ids: List<String>) {
viewLifecycleOwner.lifecycleScope.launch {
api.relationships(ids)
.fold(
onSuccess = { relationships ->
onFetchRelationshipsSuccess(mutesAdapter, relationships)
},
onFailure = { throwable ->
Log.e(TAG, "Fetch failure for relationships of accounts: $ids", throwable)
}
)
}
}
private fun onFetchRelationshipsSuccess(
mutesAdapter: MutesAdapter,
relationships: List<Relationship>
) {
val mutingNotificationsMap = HashMap<String, Boolean>()
relationships.map { mutingNotificationsMap.put(it.id, it.mutingNotifications) }
mutesAdapter.updateMutingNotificationsMap(mutingNotificationsMap)
}
private fun onFetchAccountsFailure(adapter: AccountAdapter<*>, throwable: Throwable) {
fetching = false
binding.swipeRefreshLayout.isRefreshing = false
Log.e(TAG, "Fetch failure", throwable)
if (adapter.itemCount == 0) {
binding.messageView.show()
binding.messageView.setup(throwable) {
binding.messageView.hide()
this.fetchAccounts(adapter, null)
}
}
override fun onRespondToFollowRequest(accept: Boolean, accountIdRequestingFollow: String, position: Int) {
viewModel.respondToFollowRequest(accept, accountIdRequestingFollow)
}
companion object {
private const val TAG = "AccountList" // logging tag
private const val TAG = "AccountListFragment"
private const val ARG_TYPE = "type"
private const val ARG_ID = "id"
fun newInstance(type: Type, id: String? = null): AccountListFragment {
return AccountListFragment().apply {
arguments = Bundle(3).apply {
arguments = Bundle(2).apply {
putSerializable(ARG_TYPE, type)
putString(ARG_ID, id)
}

34
app/src/main/java/com/keylesspalace/tusky/components/accountlist/AccountListPagingSource.kt

@ -0,0 +1,34 @@
/* Copyright 2025 Tusky Contributors.
*
* This file is a part of Tusky.
*
* This program is free software; you can redistribute it and/or modify it under the terms of the
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
* License, or (at your option) any later version.
*
* Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
* Public License for more details.
*
* You should have received a copy of the GNU General Public License along with Tusky; if not,
* see <http://www.gnu.org/licenses>. */
package com.keylesspalace.tusky.components.accountlist
import androidx.paging.PagingSource
import androidx.paging.PagingState
class AccountListPagingSource(
private val accounts: List<AccountViewData>,
private val nextKey: String?
) : PagingSource<String, AccountViewData>() {
override fun getRefreshKey(state: PagingState<String, AccountViewData>): String? = null
override suspend fun load(params: LoadParams<String>): LoadResult<String, AccountViewData> {
return if (params is LoadParams.Refresh) {
LoadResult.Page(accounts, null, nextKey)
} else {
LoadResult.Page(emptyList(), null, null)
}
}
}

119
app/src/main/java/com/keylesspalace/tusky/components/accountlist/AccountListRemoteMediator.kt

@ -0,0 +1,119 @@
/* Copyright 2025 Tusky Contributors.
*
* This file is a part of Tusky.
*
* This program is free software; you can redistribute it and/or modify it under the terms of the
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
* License, or (at your option) any later version.
*
* Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
* Public License for more details.
*
* You should have received a copy of the GNU General Public License along with Tusky; if not,
* see <http://www.gnu.org/licenses>. */
package com.keylesspalace.tusky.components.accountlist
import androidx.paging.ExperimentalPagingApi
import androidx.paging.LoadType
import androidx.paging.PagingState
import androidx.paging.RemoteMediator
import at.connyduck.calladapter.networkresult.getOrElse
import com.keylesspalace.tusky.components.accountlist.AccountListActivity.Type
import com.keylesspalace.tusky.entity.TimelineAccount
import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.util.HttpHeaderLink
import retrofit2.HttpException
import retrofit2.Response
@OptIn(ExperimentalPagingApi::class)
class AccountListRemoteMediator(
private val api: MastodonApi,
private val viewModel: AccountListViewModel,
private val fetchRelationships: Boolean
) : RemoteMediator<String, AccountViewData>() {
override suspend fun load(
loadType: LoadType,
state: PagingState<String, AccountViewData>
): MediatorResult {
return try {
val response = request(loadType)
?: return MediatorResult.Success(endOfPaginationReached = true)
return applyResponse(response)
} catch (e: Exception) {
MediatorResult.Error(e)
}
}
private suspend fun request(loadType: LoadType): Response<List<TimelineAccount>>? {
return when (loadType) {
LoadType.PREPEND -> null
LoadType.APPEND -> getFetchCallByListType(fromId = viewModel.nextKey)
LoadType.REFRESH -> {
viewModel.nextKey = null
viewModel.accounts.clear()
getFetchCallByListType(null)
}
}
}
private suspend fun applyResponse(response: Response<List<TimelineAccount>>): MediatorResult {
val accounts = response.body()
if (!response.isSuccessful || accounts == null) {
return MediatorResult.Error(HttpException(response))
}
val links = HttpHeaderLink.parse(response.headers()["Link"])
viewModel.nextKey = HttpHeaderLink.findByRelationType(links, "next")?.uri?.getQueryParameter("max_id")
val relationships = if (fetchRelationships) {
api.relationships(accounts.map { it.id }).getOrElse { e ->
return MediatorResult.Error(e)
}
} else {
emptyList()
}
val viewModels = accounts.map { account ->
account.toViewData(
mutingNotifications = relationships.find { it.id == account.id }?.mutingNotifications == true
)
}
viewModel.accounts.addAll(viewModels)
viewModel.invalidate()
return MediatorResult.Success(endOfPaginationReached = viewModel.nextKey == null)
}
private fun requireId(type: Type, id: String?): String {
return requireNotNull(id) { "id must not be null for type " + type.name }
}
private suspend fun getFetchCallByListType(fromId: String?): Response<List<TimelineAccount>> {
return when (viewModel.type) {
Type.FOLLOWS -> {
val accountId = requireId(viewModel.type, viewModel.accountId)
api.accountFollowing(accountId, fromId)
}
Type.FOLLOWERS -> {
val accountId = requireId(viewModel.type, viewModel.accountId)
api.accountFollowers(accountId, fromId)
}
Type.BLOCKS -> api.blocks(fromId)
Type.MUTES -> api.mutes(fromId)
Type.FOLLOW_REQUESTS -> api.followRequests(fromId)
Type.REBLOGGED -> {
val statusId = requireId(viewModel.type, viewModel.accountId)
api.statusRebloggedBy(statusId, fromId)
}
Type.FAVOURITED -> {
val statusId = requireId(viewModel.type, viewModel.accountId)
api.statusFavouritedBy(statusId, fromId)
}
}
}
}

240
app/src/main/java/com/keylesspalace/tusky/components/accountlist/AccountListViewModel.kt

@ -0,0 +1,240 @@
/* Copyright 2025 Tusky Contributors.
*
* This file is a part of Tusky.
*
* This program is free software; you can redistribute it and/or modify it under the terms of the
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
* License, or (at your option) any later version.
*
* Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
* Public License for more details.
*
* You should have received a copy of the GNU General Public License along with Tusky; if not,
* see <http://www.gnu.org/licenses>. */
package com.keylesspalace.tusky.components.accountlist
import android.view.View
import androidx.annotation.StringRes
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.paging.ExperimentalPagingApi
import androidx.paging.InvalidatingPagingSourceFactory
import androidx.paging.Pager
import androidx.paging.PagingConfig
import androidx.paging.cachedIn
import at.connyduck.calladapter.networkresult.fold
import at.connyduck.calladapter.networkresult.onFailure
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.network.MastodonApi
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
@HiltViewModel(assistedFactory = AccountListViewModel.Factory::class)
class AccountListViewModel @AssistedInject constructor(
private val api: MastodonApi,
@Assisted("type") val type: AccountListActivity.Type,
@Assisted("id") val accountId: String?
) : ViewModel() {
private val factory = InvalidatingPagingSourceFactory {
AccountListPagingSource(accounts.toList(), nextKey)
}
@OptIn(ExperimentalPagingApi::class)
val accountPager = Pager(
config = PagingConfig(40),
remoteMediator = AccountListRemoteMediator(api, this, fetchRelationships = type == AccountListActivity.Type.MUTES),
pagingSourceFactory = factory
).flow
.cachedIn(viewModelScope)
val accounts: MutableList<AccountViewData> = mutableListOf()
var nextKey: String? = null
private val _uiEvents = MutableStateFlow<List<SnackbarEvent>>(emptyList())
val uiEvents: Flow<SnackbarEvent> = _uiEvents.map { it.firstOrNull() }.filterNotNull().distinctUntilChanged()
fun invalidate() {
factory.invalidate()
}
// this is called by the mute notification toggle
fun mute(accountId: String, notifications: Boolean) {
val accountViewData = accounts.find { it.id == accountId } ?: return
viewModelScope.launch {
api.muteAccount(accountId, notifications).onFailure { e ->
sendEvent(
SnackbarEvent(
message = R.string.mute_failure,
user = "@${accountViewData.account.username}",
throwable = e,
actionText = R.string.action_retry,
action = { mute(accountId, notifications) }
)
)
}
}
}
// this is called when unmuting is undone
private fun remute(accountViewData: AccountViewData) {
viewModelScope.launch {
api.muteAccount(accountViewData.id).fold({
accounts.add(accountViewData)
invalidate()
}, { e ->
sendEvent(
SnackbarEvent(
message = R.string.mute_failure,
user = "@${accountViewData.account.username}",
throwable = e,
actionText = R.string.action_retry,
action = { block(accountViewData) }
)
)
})
}
}
fun unmute(accountId: String) {
val accountViewData = accounts.find { it.id == accountId } ?: return
viewModelScope.launch {
api.unmuteAccount(accountId).fold({
accounts.removeIf { it.id == accountId }
invalidate()
sendEvent(
SnackbarEvent(
message = R.string.unmute_success,
user = "@${accountViewData.account.username}",
throwable = null,
actionText = R.string.action_undo,
action = { remute(accountViewData) }
)
)
}, { error ->
sendEvent(
SnackbarEvent(
message = R.string.unmute_failure,
user = "@${accountViewData.account.username}",
throwable = error,
actionText = R.string.action_retry,
action = { unmute(accountId) }
)
)
})
}
}
fun unblock(accountId: String) {
val accountViewData = accounts.find { it.id == accountId } ?: return
viewModelScope.launch {
api.unblockAccount(accountId).fold({
accounts.removeIf { it.id == accountId }
invalidate()
sendEvent(
SnackbarEvent(
message = R.string.unblock_success,
user = "@${accountViewData.account.username}",
throwable = null,
actionText = R.string.action_undo,
action = { block(accountViewData) }
)
)
}, { e ->
sendEvent(
SnackbarEvent(
message = R.string.unblock_failure,
user = "@${accountViewData.account.username}",
throwable = e,
actionText = R.string.action_retry,
action = { unblock(accountId) }
)
)
})
}
}
private fun block(accountViewData: AccountViewData) {
viewModelScope.launch {
api.blockAccount(accountViewData.id).fold({
accounts.add(accountViewData)
invalidate()
}, { e ->
sendEvent(
SnackbarEvent(
message = R.string.block_failure,
user = "@${accountViewData.account.username}",
throwable = e,
actionText = R.string.action_retry,
action = { block(accountViewData) }
)
)
})
}
}
fun respondToFollowRequest(accept: Boolean, accountId: String) {
val accountViewData = accounts.find { it.id == accountId } ?: return
viewModelScope.launch {
if (accept) {
api.authorizeFollowRequest(accountId)
} else {
api.rejectFollowRequest(accountId)
}.fold({
accounts.removeIf { it.id == accountId }
invalidate()
}, { e ->
sendEvent(
SnackbarEvent(
message = if (accept) R.string.accept_follow_request_failure else R.string.reject_follow_request_failure,
user = "@${accountViewData.account.username}",
throwable = e,
actionText = R.string.action_retry,
action = { respondToFollowRequest(accept, accountId) }
)
)
})
}
}
fun consumeEvent(event: SnackbarEvent) {
println("event consumed $event")
_uiEvents.update { uiEvents ->
uiEvents - event
}
}
private fun sendEvent(event: SnackbarEvent) {
println("event sent $event")
_uiEvents.update { uiEvents ->
uiEvents + event
}
}
@AssistedFactory
interface Factory {
fun create(
@Assisted("type") type: AccountListActivity.Type,
@Assisted("id") accountId: String?
): AccountListViewModel
}
}
class SnackbarEvent(
@StringRes val message: Int,
val user: String,
@StringRes val actionText: Int,
val action: (View) -> Unit,
val throwable: Throwable? = null
)

33
app/src/main/java/com/keylesspalace/tusky/components/accountlist/AccountViewData.kt

@ -0,0 +1,33 @@
/* Copyright 2025 Tusky Contributors.
*
* This file is a part of Tusky.
*
* This program is free software; you can redistribute it and/or modify it under the terms of the
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
* License, or (at your option) any later version.
*
* Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
* Public License for more details.
*
* You should have received a copy of the GNU General Public License along with Tusky; if not,
* see <http://www.gnu.org/licenses>. */
package com.keylesspalace.tusky.components.accountlist
import com.keylesspalace.tusky.entity.TimelineAccount
data class AccountViewData(
val account: TimelineAccount,
val mutingNotifications: Boolean
) {
val id: String
get() = account.id
}
fun TimelineAccount.toViewData(
mutingNotifications: Boolean
) = AccountViewData(
account = this,
mutingNotifications = mutingNotifications
)

117
app/src/main/java/com/keylesspalace/tusky/components/accountlist/adapter/AccountAdapter.kt

@ -14,111 +14,34 @@
* see <http://www.gnu.org/licenses>. */
package com.keylesspalace.tusky.components.accountlist.adapter
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.paging.PagingDataAdapter
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.RecyclerView
import com.keylesspalace.tusky.databinding.ItemFooterBinding
import com.keylesspalace.tusky.entity.TimelineAccount
import com.keylesspalace.tusky.components.accountlist.AccountViewData
import com.keylesspalace.tusky.interfaces.AccountActionListener
import com.keylesspalace.tusky.util.BindingHolder
import com.keylesspalace.tusky.util.removeDuplicatesTo
/** Generic adapter with bottom loading indicator. */
abstract class AccountAdapter<AVH : RecyclerView.ViewHolder> internal constructor(
abstract class AccountAdapter<AVH : RecyclerView.ViewHolder>(
protected val accountActionListener: AccountActionListener,
protected val animateAvatar: Boolean,
protected val animateEmojis: Boolean,
protected val showBotOverlay: Boolean
) : RecyclerView.Adapter<RecyclerView.ViewHolder?>() {
protected var accountList: MutableList<TimelineAccount> = mutableListOf()
private var bottomLoading: Boolean = false
override fun getItemCount(): Int {
return accountList.size + if (bottomLoading) 1 else 0
}
abstract fun createAccountViewHolder(parent: ViewGroup): AVH
abstract fun onBindAccountViewHolder(viewHolder: AVH, position: Int)
final override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
if (getItemViewType(position) == VIEW_TYPE_ACCOUNT) {
@Suppress("UNCHECKED_CAST")
this.onBindAccountViewHolder(holder as AVH, position)
}
}
final override fun onCreateViewHolder(
parent: ViewGroup,
viewType: Int
): RecyclerView.ViewHolder {
return when (viewType) {
VIEW_TYPE_ACCOUNT -> this.createAccountViewHolder(parent)
VIEW_TYPE_FOOTER -> this.createFooterViewHolder(parent)
else -> error("Unknown item type: $viewType")
}
}
private fun createFooterViewHolder(parent: ViewGroup): RecyclerView.ViewHolder {
val binding = ItemFooterBinding.inflate(LayoutInflater.from(parent.context), parent, false)
return BindingHolder(binding)
}
override fun getItemViewType(position: Int): Int {
return if (position == accountList.size && bottomLoading) {
VIEW_TYPE_FOOTER
} else {
VIEW_TYPE_ACCOUNT
}
}
fun update(newAccounts: List<TimelineAccount>) {
accountList = newAccounts.removeDuplicatesTo(ArrayList())
notifyDataSetChanged()
}
fun addItems(newAccounts: List<TimelineAccount>) {
val end = accountList.size
val last = accountList[end - 1]
if (newAccounts.none { it.id == last.id }) {
accountList.addAll(newAccounts)
notifyItemRangeInserted(end, newAccounts.size)
}
}
fun setBottomLoading(loading: Boolean) {
val wasLoading = bottomLoading
if (wasLoading == loading) {
return
}
bottomLoading = loading
if (loading) {
notifyItemInserted(accountList.size)
} else {
notifyItemRemoved(accountList.size)
}
}
fun removeItem(position: Int): TimelineAccount? {
if (position < 0 || position >= accountList.size) {
return null
}
val account = accountList.removeAt(position)
notifyItemRemoved(position)
return account
}
fun addItem(account: TimelineAccount, position: Int) {
if (position < 0 || position > accountList.size) {
return
}
accountList.add(position, account)
notifyItemInserted(position)
}
) : PagingDataAdapter<AccountViewData, AVH>(AccountViewDataDifferCallback) {
companion object {
const val VIEW_TYPE_ACCOUNT = 0
const val VIEW_TYPE_FOOTER = 1
private val AccountViewDataDifferCallback = object : DiffUtil.ItemCallback<AccountViewData>() {
override fun areItemsTheSame(
oldItem: AccountViewData,
newItem: AccountViewData
): Boolean {
return oldItem.id == newItem.id
}
override fun areContentsTheSame(
oldItem: AccountViewData,
newItem: AccountViewData
): Boolean {
return oldItem == newItem
}
}
}
}

54
app/src/main/java/com/keylesspalace/tusky/components/accountlist/adapter/BlocksAdapter.kt

@ -38,42 +38,38 @@ class BlocksAdapter(
showBotOverlay = showBotOverlay
) {
override fun createAccountViewHolder(parent: ViewGroup): BindingHolder<ItemBlockedUserBinding> {
val binding = ItemBlockedUserBinding.inflate(
LayoutInflater.from(parent.context),
parent,
false
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BindingHolder<ItemBlockedUserBinding> {
return BindingHolder(
ItemBlockedUserBinding.inflate(LayoutInflater.from(parent.context), parent, false)
)
return BindingHolder(binding)
}
override fun onBindAccountViewHolder(
viewHolder: BindingHolder<ItemBlockedUserBinding>,
position: Int
) {
val account = accountList[position]
val binding = viewHolder.binding
val context = binding.root.context
override fun onBindViewHolder(viewHolder: BindingHolder<ItemBlockedUserBinding>, position: Int) {
getItem(position)?.let { viewData ->
val account = viewData.account
val binding = viewHolder.binding
val context = binding.root.context
val emojifiedName = account.name.emojify(
account.emojis,
binding.blockedUserDisplayName,
animateEmojis
)
binding.blockedUserDisplayName.text = emojifiedName
val formattedUsername = context.getString(R.string.post_username_format, account.username)
binding.blockedUserUsername.text = formattedUsername
val emojifiedName = account.name.emojify(
account.emojis,
binding.blockedUserDisplayName,
animateEmojis
)
binding.blockedUserDisplayName.text = emojifiedName
val formattedUsername = context.getString(R.string.post_username_format, account.username)
binding.blockedUserUsername.text = formattedUsername
val avatarRadius = context.resources.getDimensionPixelSize(R.dimen.avatar_radius_48dp)
loadAvatar(account.avatar, binding.blockedUserAvatar, avatarRadius, animateAvatar)
val avatarRadius = context.resources.getDimensionPixelSize(R.dimen.avatar_radius_48dp)
loadAvatar(account.avatar, binding.blockedUserAvatar, avatarRadius, animateAvatar)
binding.blockedUserBotBadge.visible(showBotOverlay && account.bot)
binding.blockedUserBotBadge.visible(showBotOverlay && account.bot)
binding.blockedUserUnblock.setOnClickListener {
accountActionListener.onBlock(false, account.id, position)
}
binding.root.setOnClickListener {
accountActionListener.onViewAccount(account.id)
binding.blockedUserUnblock.setOnClickListener {
accountActionListener.onBlock(false, account.id, position)
}
binding.root.setOnClickListener {
accountActionListener.onViewAccount(account.id)
}
}
}
}

20
app/src/main/java/com/keylesspalace/tusky/components/accountlist/adapter/FollowAdapter.kt

@ -34,18 +34,20 @@ class FollowAdapter(
showBotOverlay = showBotOverlay
) {
override fun createAccountViewHolder(parent: ViewGroup): AccountViewHolder {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): AccountViewHolder {
val binding = ItemAccountBinding.inflate(LayoutInflater.from(parent.context), parent, false)
return AccountViewHolder(binding)
}
override fun onBindAccountViewHolder(viewHolder: AccountViewHolder, position: Int) {
viewHolder.setupWithAccount(
accountList[position],
animateAvatar,
animateEmojis,
showBotOverlay
)
viewHolder.setupActionListener(accountActionListener)
override fun onBindViewHolder(viewHolder: AccountViewHolder, position: Int) {
getItem(position)?.let { viewData ->
viewHolder.setupWithAccount(
viewData.account,
animateAvatar,
animateEmojis,
showBotOverlay
)
viewHolder.setupActionListener(accountActionListener)
}
}
}

34
app/src/main/java/com/keylesspalace/tusky/components/accountlist/adapter/FollowRequestsAdapter.kt

@ -35,28 +35,20 @@ class FollowRequestsAdapter(
animateEmojis = animateEmojis,
showBotOverlay = showBotOverlay
) {
override fun createAccountViewHolder(parent: ViewGroup): FollowRequestViewHolder {
val binding = ItemFollowRequestBinding.inflate(
LayoutInflater.from(parent.context),
parent,
false
)
return FollowRequestViewHolder(
binding,
accountActionListener,
linkListener,
showHeader = false
)
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): FollowRequestViewHolder {
val binding = ItemFollowRequestBinding.inflate(LayoutInflater.from(parent.context), parent, false)
return FollowRequestViewHolder(binding, accountActionListener, linkListener, showHeader = false)
}
override fun onBindAccountViewHolder(viewHolder: FollowRequestViewHolder, position: Int) {
viewHolder.setupWithAccount(
account = accountList[position],
animateAvatar = animateAvatar,
animateEmojis = animateEmojis,
showBotOverlay = showBotOverlay
)
viewHolder.setupActionListener(accountActionListener, accountList[position].id)
override fun onBindViewHolder(viewHolder: FollowRequestViewHolder, position: Int) {
getItem(position)?.let { viewData ->
viewHolder.setupWithAccount(
account = viewData.account,
animateAvatar = animateAvatar,
animateEmojis = animateEmojis,
showBotOverlay = showBotOverlay
)
viewHolder.setupActionListener(accountActionListener, viewData.account.id)
}
}
}

118
app/src/main/java/com/keylesspalace/tusky/components/accountlist/adapter/MutesAdapter.kt

@ -39,82 +39,58 @@ class MutesAdapter(
showBotOverlay = showBotOverlay
) {
private val mutingNotificationsMap = HashMap<String, Boolean>()
override fun createAccountViewHolder(parent: ViewGroup): BindingHolder<ItemMutedUserBinding> {
val binding = ItemMutedUserBinding.inflate(
LayoutInflater.from(parent.context),
parent,
false
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BindingHolder<ItemMutedUserBinding> {
return BindingHolder(
ItemMutedUserBinding.inflate(LayoutInflater.from(parent.context), parent, false)
)
return BindingHolder(binding)
}
override fun onBindAccountViewHolder(
viewHolder: BindingHolder<ItemMutedUserBinding>,
position: Int
) {
val account = accountList[position]
val binding = viewHolder.binding
val context = binding.root.context
val mutingNotifications = mutingNotificationsMap[account.id]
val emojifiedName = account.name.emojify(
account.emojis,
binding.mutedUserDisplayName,
animateEmojis
)
binding.mutedUserDisplayName.text = emojifiedName
val formattedUsername = context.getString(R.string.post_username_format, account.username)
binding.mutedUserUsername.text = formattedUsername
val avatarRadius = context.resources.getDimensionPixelSize(R.dimen.avatar_radius_48dp)
loadAvatar(account.avatar, binding.mutedUserAvatar, avatarRadius, animateAvatar)
binding.mutedUserBotBadge.visible(showBotOverlay && account.bot)
val unmuteString = context.getString(R.string.action_unmute_desc, formattedUsername)
binding.mutedUserUnmute.contentDescription = unmuteString
ViewCompat.setTooltipText(binding.mutedUserUnmute, unmuteString)
binding.mutedUserMuteNotifications.setOnCheckedChangeListener(null)
override fun onBindViewHolder(viewHolder: BindingHolder<ItemMutedUserBinding>, position: Int) {
getItem(position)?.let { viewData ->
val account = viewData.account
val binding = viewHolder.binding
val context = binding.root.context
binding.mutedUserMuteNotifications.isChecked = if (mutingNotifications == null) {
binding.mutedUserMuteNotifications.isEnabled = false
true
} else {
binding.mutedUserMuteNotifications.isEnabled = true
mutingNotifications
}
binding.mutedUserUnmute.setOnClickListener {
accountActionListener.onMute(
false,
account.id,
viewHolder.bindingAdapterPosition,
false
)
}
binding.mutedUserMuteNotifications.setOnCheckedChangeListener { _, isChecked ->
accountActionListener.onMute(
true,
account.id,
viewHolder.bindingAdapterPosition,
isChecked
val emojifiedName = account.name.emojify(
account.emojis,
binding.mutedUserDisplayName,
animateEmojis
)
binding.mutedUserDisplayName.text = emojifiedName
val formattedUsername = context.getString(R.string.post_username_format, account.username)
binding.mutedUserUsername.text = formattedUsername
val avatarRadius = context.resources.getDimensionPixelSize(R.dimen.avatar_radius_48dp)
loadAvatar(account.avatar, binding.mutedUserAvatar, avatarRadius, animateAvatar)
binding.mutedUserBotBadge.visible(showBotOverlay && account.bot)
val unmuteString = context.getString(R.string.action_unmute_desc, formattedUsername)
binding.mutedUserUnmute.contentDescription = unmuteString
ViewCompat.setTooltipText(binding.mutedUserUnmute, unmuteString)
binding.mutedUserMuteNotifications.setOnCheckedChangeListener(null)
binding.mutedUserMuteNotifications.isChecked = viewData.mutingNotifications
binding.mutedUserUnmute.setOnClickListener {
accountActionListener.onMute(
false,
account.id,
viewHolder.bindingAdapterPosition,
false
)
}
binding.mutedUserMuteNotifications.setOnCheckedChangeListener { _, isChecked ->
accountActionListener.onMute(
true,
account.id,
viewHolder.bindingAdapterPosition,
isChecked
)
}
binding.root.setOnClickListener { accountActionListener.onViewAccount(account.id) }
}
binding.root.setOnClickListener { accountActionListener.onViewAccount(account.id) }
}
fun updateMutingNotifications(id: String, mutingNotifications: Boolean, position: Int) {
mutingNotificationsMap[id] = mutingNotifications
notifyItemChanged(position)
}
fun updateMutingNotificationsMap(newMutingNotificationsMap: HashMap<String, Boolean>) {
mutingNotificationsMap.putAll(newMutingNotificationsMap)
notifyDataSetChanged()
}
}

42
app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationAdapter.kt → app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationPagingAdapter.kt

@ -19,15 +19,18 @@ import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.paging.PagingDataAdapter
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.RecyclerView
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.adapter.PlaceholderViewHolder
import com.keylesspalace.tusky.adapter.StatusBaseViewHolder
import com.keylesspalace.tusky.databinding.ItemPlaceholderBinding
import com.keylesspalace.tusky.interfaces.StatusActionListener
import com.keylesspalace.tusky.util.StatusDisplayOptions
class ConversationAdapter(
class ConversationPagingAdapter(
private var statusDisplayOptions: StatusDisplayOptions,
private val listener: StatusActionListener
) : PagingDataAdapter<ConversationViewData, ConversationViewHolder>(CONVERSATION_COMPARATOR) {
) : PagingDataAdapter<ConversationViewData, RecyclerView.ViewHolder>(CONVERSATION_COMPARATOR) {
var mediaPreviewEnabled: Boolean
get() = statusDisplayOptions.mediaPreviewEnabled
@ -37,25 +40,42 @@ class ConversationAdapter(
)
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ConversationViewHolder {
val view = LayoutInflater.from(
parent.context
).inflate(R.layout.item_conversation, parent, false)
return ConversationViewHolder(view, statusDisplayOptions, listener)
override fun getItemViewType(position: Int): Int {
return if (getItem(position) == null) {
VIEW_TYPE_PLACEHOLDER
} else {
VIEW_TYPE_CONVERSATION
}
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
val layoutInflater = LayoutInflater.from(parent.context)
return if (viewType == VIEW_TYPE_CONVERSATION) {
ConversationViewHolder(layoutInflater.inflate(R.layout.item_conversation, parent, false), statusDisplayOptions, listener)
} else {
PlaceholderViewHolder(
ItemPlaceholderBinding.inflate(layoutInflater, parent, false),
mode = PlaceholderViewHolder.Mode.CONVERSATION
)
}
}
override fun onBindViewHolder(holder: ConversationViewHolder, position: Int) {
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
onBindViewHolder(holder, position, emptyList())
}
override fun onBindViewHolder(holder: ConversationViewHolder, position: Int, payloads: List<Any>) {
getItem(position)?.let { conversationViewData ->
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int, payloads: List<Any>) {
val conversationViewData = getItem(position)
if (holder is ConversationViewHolder && conversationViewData != null) {
holder.setupWithConversation(conversationViewData, payloads)
}
}
companion object {
val CONVERSATION_COMPARATOR = object : DiffUtil.ItemCallback<ConversationViewData>() {
private const val VIEW_TYPE_PLACEHOLDER = 0
private const val VIEW_TYPE_CONVERSATION = 1
private val CONVERSATION_COMPARATOR = object : DiffUtil.ItemCallback<ConversationViewData>() {
override fun areItemsTheSame(
oldItem: ConversationViewData,
newItem: ConversationViewData

2
app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationViewHolder.java

@ -96,7 +96,7 @@ public class ConversationViewHolder extends StatusBaseViewHolder {
mediaContainer.setVisibility(View.VISIBLE);
setMediaPreviews(attachments, sensitive, listener, statusViewData.isShowingContent(),
statusDisplayOptions.useBlurhash());
statusDisplayOptions.useBlurhash(), statusViewData.getFilter());
if (attachments.isEmpty()) {
hideSensitiveMediaWarning();

39
app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsFragment.kt

@ -31,10 +31,12 @@ import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import androidx.recyclerview.widget.SimpleItemAnimator
import at.connyduck.sparkbutton.SparkButton
import at.connyduck.sparkbutton.helpers.Utils
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.StatusListActivity
import com.keylesspalace.tusky.adapter.LoadStateFooterAdapter
import com.keylesspalace.tusky.appstore.ConversationsLoadingEvent
import com.keylesspalace.tusky.appstore.EventHub
import com.keylesspalace.tusky.appstore.PreferenceChangedEvent
@ -53,6 +55,7 @@ import com.keylesspalace.tusky.util.isAnyLoading
import com.keylesspalace.tusky.util.show
import com.keylesspalace.tusky.util.updateRelativeTimePeriodically
import com.keylesspalace.tusky.util.viewBinding
import com.keylesspalace.tusky.view.ConfirmationBottomSheet.Companion.confirmFavourite
import com.keylesspalace.tusky.viewdata.AttachmentViewData
import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject
@ -76,7 +79,9 @@ class ConversationsFragment :
private val binding by viewBinding(FragmentTimelineBinding::bind)
private var adapter: ConversationAdapter? = null
private var adapter: ConversationPagingAdapter? = null
private var buttonToAnimate: SparkButton? = null
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
requireActivity().addMenuProvider(this, viewLifecycleOwner, Lifecycle.State.RESUMED)
@ -88,8 +93,6 @@ class ConversationsFragment :
showBotOverlay = preferences.getBoolean(PrefKeys.SHOW_BOT_OVERLAY, true),
useBlurhash = preferences.getBoolean(PrefKeys.USE_BLURHASH, true),
cardViewMode = CardViewMode.NONE,
confirmReblogs = preferences.getBoolean(PrefKeys.CONFIRM_REBLOGS, true),
confirmFavourites = preferences.getBoolean(PrefKeys.CONFIRM_FAVOURITES, false),
hideStats = preferences.getBoolean(PrefKeys.WELLBEING_HIDE_STATS_POSTS, false),
animateEmojis = preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false),
showStatsInline = preferences.getBoolean(PrefKeys.SHOW_STATS_INLINE, false),
@ -97,7 +100,7 @@ class ConversationsFragment :
openSpoiler = accountManager.activeAccount!!.alwaysOpenSpoiler
)
val adapter = ConversationAdapter(statusDisplayOptions, this)
val adapter = ConversationPagingAdapter(statusDisplayOptions, this)
this.adapter = adapter
setupRecyclerView(adapter)
@ -193,6 +196,7 @@ class ConversationsFragment :
override fun onDestroyView() {
// Clear the adapter to prevent leaking the View
adapter = null
buttonToAnimate = null
super.onDestroyView()
}
@ -212,7 +216,7 @@ class ConversationsFragment :
}
}
private fun setupRecyclerView(adapter: ConversationAdapter) {
private fun setupRecyclerView(adapter: ConversationPagingAdapter) {
binding.recyclerView.ensureBottomPadding(fab = true)
binding.recyclerView.setHasFixedSize(true)
binding.recyclerView.layoutManager = LinearLayoutManager(context)
@ -224,26 +228,37 @@ class ConversationsFragment :
(binding.recyclerView.itemAnimator as SimpleItemAnimator).supportsChangeAnimations = false
binding.recyclerView.adapter =
adapter.withLoadStateFooter(ConversationLoadStateAdapter(adapter::retry))
adapter.withLoadStateFooter(LoadStateFooterAdapter(adapter::retry))
}
private fun refreshContent() {
adapter?.refresh()
}
override fun onReblog(reblog: Boolean, position: Int, visibility: Status.Visibility) {
override fun onReblog(reblog: Boolean, position: Int, visibility: Status.Visibility?, button: SparkButton?) {
// its impossible to reblog private messages
}
override fun onFavourite(favourite: Boolean, position: Int) {
override fun onFavourite(favourite: Boolean, position: Int, button: SparkButton?) {
adapter?.peek(position)?.let { conversation ->
viewModel.favourite(favourite, conversation)
buttonToAnimate = button
if (favourite) {
confirmFavourite(preferences) {
viewModel.favourite(true, conversation)
buttonToAnimate?.playAnimation()
buttonToAnimate?.isChecked = true
}
} else {
viewModel.favourite(false, conversation)
buttonToAnimate?.isChecked = false
}
}
}
override fun onBookmark(favourite: Boolean, position: Int) {
override fun onBookmark(bookmark: Boolean, position: Int) {
adapter?.peek(position)?.let { conversation ->
viewModel.bookmark(favourite, conversation)
viewModel.bookmark(bookmark, conversation)
}
}
@ -371,7 +386,7 @@ class ConversationsFragment :
.show()
}
private fun onPreferenceChanged(adapter: ConversationAdapter, key: String) {
private fun onPreferenceChanged(adapter: ConversationPagingAdapter, key: String) {
when (key) {
PrefKeys.MEDIA_PREVIEW_ENABLED -> {
val enabled = accountManager.activeAccount!!.mediaPreviewEnabled

6
app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsViewModel.kt

@ -112,9 +112,9 @@ class ConversationsViewModel @Inject constructor(
fun showPollResults(conversation: ConversationViewData) = viewModelScope.launch {
conversation.lastStatus.status.poll?.let { poll ->
conversation.toEntity(accountId = accountId, poll = poll.copy(voted = true)).let {
saveConversationToDb(it)
}
saveConversationToDb(
conversation.toEntity(accountId = accountId, poll = poll.copy(voted = true))
)
}
}

34
app/src/main/java/com/keylesspalace/tusky/components/filters/EditFilterActivity.kt

@ -35,6 +35,7 @@ import com.keylesspalace.tusky.BaseActivity
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.appstore.EventHub
import com.keylesspalace.tusky.appstore.FilterUpdatedEvent
import com.keylesspalace.tusky.components.instanceinfo.InstanceInfoRepository
import com.keylesspalace.tusky.databinding.ActivityEditFilterBinding
import com.keylesspalace.tusky.databinding.DialogFilterBinding
import com.keylesspalace.tusky.entity.Filter
@ -56,6 +57,9 @@ class EditFilterActivity : BaseActivity() {
@Inject
lateinit var eventHub: EventHub
@Inject
lateinit var instanceInfoRepository: InstanceInfoRepository
private val binding by viewBinding(ActivityEditFilterBinding::inflate)
private val viewModel: EditFilterViewModel by viewModels()
@ -67,7 +71,7 @@ class EditFilterActivity : BaseActivity() {
super.onCreate(savedInstanceState)
originalFilter = intent.getParcelableExtraCompat(FILTER_TO_EDIT)
filter = originalFilter ?: Filter("", "", listOf(), null, Filter.Action.WARN.action, listOf())
filter = originalFilter ?: Filter(context = emptyList(), action = Filter.Action.WARN)
binding.apply {
contextSwitches = mapOf(
filterContextHome to Filter.Kind.HOME,
@ -124,14 +128,17 @@ class EditFilterActivity : BaseActivity() {
viewModel.setTitle(editable.toString())
validateSaveButton()
}
binding.filterActionWarn.setOnCheckedChangeListener { _, checked ->
viewModel.setAction(
if (checked) {
Filter.Action.WARN
} else {
Filter.Action.HIDE
}
)
// blur filter is supported in mastodon api version 5+
val blurFilterSupported = instanceInfoRepository.cachedInstanceInfoOrFallback.mastodonApiVersion?.let { it >= 5 } == true
binding.filterActionBlur.visible(blurFilterSupported)
binding.filterActionGroup.setOnCheckedChangeListener { _, checkedId ->
val action = when (checkedId) {
R.id.filter_action_blur -> Filter.Action.BLUR
R.id.filter_action_hide -> Filter.Action.HIDE
else -> Filter.Action.WARN
}
viewModel.setAction(action)
}
binding.filterDurationDropDown.onItemClickListener = AdapterView.OnItemClickListener { _, _, position, _ ->
viewModel.setDuration(
@ -178,6 +185,7 @@ class EditFilterActivity : BaseActivity() {
lifecycleScope.launch {
viewModel.action.collect { action ->
when (action) {
Filter.Action.BLUR -> binding.filterActionBlur.isChecked = true
Filter.Action.HIDE -> binding.filterActionHide.isChecked = true
else -> binding.filterActionWarn.isChecked = true
}
@ -299,14 +307,14 @@ class EditFilterActivity : BaseActivity() {
if (viewModel.saveChanges(this@EditFilterActivity)) {
finish()
// Possibly affected contexts: any context affected by the original filter OR any context affected by the updated filter
val affectedContexts = viewModel.contexts.value.map {
it.kind
}.union(originalFilter?.context ?: listOf()).distinct()
val affectedContexts = viewModel.contexts.value
.union(originalFilter?.context.orEmpty())
.distinct()
eventHub.dispatch(FilterUpdatedEvent(affectedContexts))
} else {
Snackbar.make(
binding.root,
getString(R.string.error_deleting_filter, viewModel.title.value),
getString(R.string.error_saving_filter, viewModel.title.value),
Snackbar.LENGTH_SHORT
).show()
}

18
app/src/main/java/com/keylesspalace/tusky/components/filters/EditFilterViewModel.kt

@ -60,7 +60,7 @@ class EditFilterViewModel @Inject constructor(val api: MastodonApi) : ViewModel(
} else {
-1
}
_contexts.value = filter.kinds
_contexts.value = filter.context
}
fun addKeyword(keyword: FilterKeyword) {
@ -109,10 +109,10 @@ class EditFilterViewModel @Inject constructor(val api: MastodonApi) : ViewModel(
}
suspend fun saveChanges(context: Context): Boolean {
val contexts = _contexts.value.map { it.kind }
val contexts = _contexts.value
val title = _title.value
val durationIndex = _duration.value
val action = _action.value.action
val action = _action.value
return withContext(viewModelScope.coroutineContext) {
originalFilter?.let { filter ->
@ -123,8 +123,8 @@ class EditFilterViewModel @Inject constructor(val api: MastodonApi) : ViewModel(
private suspend fun createFilter(
title: String,
contexts: List<String>,
action: String,
contexts: List<Filter.Kind>,
action: Filter.Action,
durationIndex: Int,
context: Context
): Boolean {
@ -149,7 +149,7 @@ class EditFilterViewModel @Inject constructor(val api: MastodonApi) : ViewModel(
return (
throwable.isHttpNotFound() &&
// Endpoint not found, fall back to v1 api
createFilterV1(contexts, expiration)
createFilterV1(contexts.map(Filter.Kind::kind), expiration)
)
}
)
@ -158,8 +158,8 @@ class EditFilterViewModel @Inject constructor(val api: MastodonApi) : ViewModel(
private suspend fun updateFilter(
originalFilter: Filter,
title: String,
contexts: List<String>,
action: String,
contexts: List<Filter.Kind>,
action: Filter.Action,
durationIndex: Int,
context: Context
): Boolean {
@ -189,7 +189,7 @@ class EditFilterViewModel @Inject constructor(val api: MastodonApi) : ViewModel(
{ throwable ->
if (throwable.isHttpNotFound()) {
// Endpoint not found, fall back to v1 api
if (updateFilterV1(contexts, expiration)) {
if (updateFilterV1(contexts.map(Filter.Kind::kind), expiration)) {
return true
}
}

2
app/src/main/java/com/keylesspalace/tusky/components/filters/FiltersAdapter.kt

@ -43,7 +43,7 @@ class FiltersAdapter(val listener: FiltersListener, val filters: List<Filter>) :
binding.textSecondary.text = context.getString(
R.string.filter_description_format,
actions.getOrNull(filter.action.ordinal - 1),
filter.context.map { contexts.getOrNull(Filter.Kind.from(it).ordinal) }.joinToString("/")
filter.context.map { contexts.getOrNull(it.ordinal) }.joinToString("/")
)
binding.delete.setOnClickListener {

1
app/src/main/java/com/keylesspalace/tusky/components/instanceinfo/InstanceInfo.kt

@ -31,5 +31,6 @@ data class InstanceInfo(
val maxFieldValueLength: Int?,
val version: String?,
val translationEnabled: Boolean?,
val mastodonApiVersion: Int?,
val vapidKey: String?
)

3
app/src/main/java/com/keylesspalace/tusky/components/instanceinfo/InstanceInfoRepository.kt

@ -146,6 +146,7 @@ class InstanceInfoRepository @Inject constructor(
maxFieldValueLength = this?.maxFieldValueLength,
version = this?.version,
translationEnabled = this?.translationEnabled,
mastodonApiVersion = this?.mastodonApiVersion,
vapidKey = this?.vapidKey
)
@ -175,6 +176,7 @@ class InstanceInfoRepository @Inject constructor(
maxFieldNameLength = this.pleroma?.metadata?.fieldLimits?.nameLength,
maxFieldValueLength = this.pleroma?.metadata?.fieldLimits?.valueLength,
translationEnabled = this.configuration?.translation?.enabled,
mastodonApiVersion = this.apiVersions?.mastodon,
vapidKey = this.configuration?.vapid?.publicKey
)
@ -204,6 +206,7 @@ class InstanceInfoRepository @Inject constructor(
maxFieldNameLength = this.pleroma?.metadata?.fieldLimits?.nameLength,
maxFieldValueLength = this.pleroma?.metadata?.fieldLimits?.valueLength,
translationEnabled = null,
mastodonApiVersion = null,
vapidKey = null
)

16
app/src/main/java/com/keylesspalace/tusky/components/login/LoginActivity.kt

@ -24,6 +24,7 @@ import android.util.Log
import android.view.Menu
import android.view.View
import android.widget.TextView
import androidx.core.content.edit
import androidx.core.net.toUri
import androidx.core.view.WindowInsetsCompat.Type.ime
import androidx.core.view.WindowInsetsCompat.Type.systemBars
@ -138,7 +139,7 @@ class LoginActivity : BaseActivity() {
try {
HttpUrl.Builder().host(domain).scheme("https").build()
} catch (e: IllegalArgumentException) {
} catch (_: IllegalArgumentException) {
setLoading(false)
binding.domainTextInputLayout.error = getString(R.string.error_invalid_domain)
return
@ -161,11 +162,11 @@ class LoginActivity : BaseActivity() {
).fold(
{ credentials ->
// Save credentials so we can access them after we opened another activity for auth.
preferences.edit()
.putString(DOMAIN, domain)
.putString(CLIENT_ID, credentials.clientId)
.putString(CLIENT_SECRET, credentials.clientSecret)
.apply()
preferences.edit {
putString(DOMAIN, domain)
putString(CLIENT_ID, credentials.clientId)
putString(CLIENT_SECRET, credentials.clientSecret)
}
redirectUserToAuthorizeAndLogin(domain, credentials.clientId, openInWebView)
},
@ -291,9 +292,8 @@ class LoginActivity : BaseActivity() {
oauthScopes = OAUTH_SCOPES,
newAccount = newAccount
)
finishAffinity()
val intent = Intent(this, MainActivity::class.java)
intent.putExtra(MainActivity.OPEN_WITH_EXPLODE_ANIMATION, true)
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK)
startActivity(intent)
}, { e ->
setLoading(false)

11
app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationTypeMappers.kt

@ -15,13 +15,14 @@
package com.keylesspalace.tusky.components.notifications
import com.keylesspalace.tusky.components.timeline.Placeholder
import com.keylesspalace.tusky.components.timeline.LoadMorePlaceholder
import com.keylesspalace.tusky.components.timeline.toAccount
import com.keylesspalace.tusky.components.timeline.toStatus
import com.keylesspalace.tusky.db.entity.NotificationDataEntity
import com.keylesspalace.tusky.db.entity.NotificationEntity
import com.keylesspalace.tusky.db.entity.NotificationReportEntity
import com.keylesspalace.tusky.db.entity.TimelineAccountEntity
import com.keylesspalace.tusky.entity.Filter
import com.keylesspalace.tusky.entity.Notification
import com.keylesspalace.tusky.entity.Report
import com.keylesspalace.tusky.util.toViewData
@ -29,7 +30,7 @@ import com.keylesspalace.tusky.viewdata.NotificationViewData
import com.keylesspalace.tusky.viewdata.StatusViewData
import com.keylesspalace.tusky.viewdata.TranslationViewData
fun Placeholder.toNotificationEntity(
fun LoadMorePlaceholder.toNotificationEntity(
tuskyAccountId: Long
) = NotificationEntity(
id = this.id,
@ -61,6 +62,7 @@ fun Notification.toViewData(
isShowingContent: Boolean,
isExpanded: Boolean,
isCollapsed: Boolean,
filter: Filter?,
): NotificationViewData.Concrete = NotificationViewData.Concrete(
id = id,
type = type,
@ -68,7 +70,8 @@ fun Notification.toViewData(
statusViewData = status?.toViewData(
isShowingContent = isShowingContent,
isExpanded = isExpanded,
isCollapsed = isCollapsed
isCollapsed = isCollapsed,
filter = filter,
),
report = report,
moderationWarning = moderationWarning,
@ -90,7 +93,7 @@ fun NotificationDataEntity.toViewData(
translation: TranslationViewData? = null
): NotificationViewData {
if (type == null || account == null) {
return NotificationViewData.Placeholder(id = id, isLoading = loading)
return NotificationViewData.LoadMore(id = id, isLoading = loading)
}
return NotificationViewData.Concrete(

41
app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsFragment.kt

@ -41,6 +41,7 @@ import androidx.recyclerview.widget.RecyclerView
import androidx.recyclerview.widget.SimpleItemAnimator
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
import at.connyduck.calladapter.networkresult.onFailure
import at.connyduck.sparkbutton.SparkButton
import at.connyduck.sparkbutton.helpers.Utils
import com.google.android.material.appbar.AppBarLayout
import com.google.android.material.dialog.MaterialAlertDialogBuilder
@ -72,6 +73,8 @@ import com.keylesspalace.tusky.util.show
import com.keylesspalace.tusky.util.startActivityWithSlideInAnimation
import com.keylesspalace.tusky.util.updateRelativeTimePeriodically
import com.keylesspalace.tusky.util.viewBinding
import com.keylesspalace.tusky.view.ConfirmationBottomSheet.Companion.confirmFavourite
import com.keylesspalace.tusky.view.ConfirmationBottomSheet.Companion.confirmReblog
import com.keylesspalace.tusky.viewdata.AttachmentViewData
import com.keylesspalace.tusky.viewdata.NotificationViewData
import com.keylesspalace.tusky.viewdata.TranslationViewData
@ -113,6 +116,8 @@ class NotificationsFragment :
private var loadMorePosition: Int? = null
private var statusIdBelowLoadMore: String? = null
private var buttonToAnimate: SparkButton? = null
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
requireActivity().addMenuProvider(this, viewLifecycleOwner, Lifecycle.State.RESUMED)
@ -129,8 +134,6 @@ class NotificationsFragment :
} else {
CardViewMode.NONE
},
confirmReblogs = preferences.getBoolean(PrefKeys.CONFIRM_REBLOGS, true),
confirmFavourites = preferences.getBoolean(PrefKeys.CONFIRM_FAVOURITES, false),
hideStats = preferences.getBoolean(PrefKeys.WELLBEING_HIDE_STATS_POSTS, false),
animateEmojis = preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false),
showStatsInline = preferences.getBoolean(PrefKeys.SHOW_STATS_INLINE, false),
@ -284,6 +287,7 @@ class NotificationsFragment :
// Clear the adapters to prevent leaking the View
notificationsAdapter = null
notificationsPolicyAdapter = null
buttonToAnimate = null
super.onDestroyView()
}
@ -336,9 +340,23 @@ class NotificationsFragment :
viewModel.remove(notification.id)
}
override fun onReblog(reblog: Boolean, position: Int, visibility: Status.Visibility) {
override fun onReblog(reblog: Boolean, position: Int, visibility: Status.Visibility?, button: SparkButton?) {
val status = notificationsAdapter?.peek(position)?.asStatusOrNull() ?: return
viewModel.reblog(reblog, status, visibility)
buttonToAnimate = button
if (reblog && visibility == null) {
confirmReblog(preferences) { visibility ->
viewModel.reblog(true, status, visibility)
buttonToAnimate?.playAnimation()
buttonToAnimate?.isChecked = true
}
} else {
viewModel.reblog(reblog, status, visibility ?: Status.Visibility.PUBLIC)
if (reblog) {
buttonToAnimate?.playAnimation()
}
buttonToAnimate?.isChecked = reblog
}
}
override val onMoreTranslate: (translate: Boolean, position: Int) -> Unit
@ -369,9 +387,20 @@ class NotificationsFragment :
viewModel.untranslate(status)
}
override fun onFavourite(favourite: Boolean, position: Int) {
override fun onFavourite(favourite: Boolean, position: Int, button: SparkButton?) {
val status = notificationsAdapter?.peek(position)?.asStatusOrNull() ?: return
viewModel.favorite(favourite, status)
buttonToAnimate = button
if (favourite) {
confirmFavourite(preferences) {
viewModel.favorite(true, status)
buttonToAnimate?.playAnimation()
buttonToAnimate?.isChecked = true
}
} else {
viewModel.favorite(false, status)
buttonToAnimate?.isChecked = false
}
}
override fun onBookmark(bookmark: Boolean, position: Int) {

44
app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsPagingAdapter.kt

@ -23,16 +23,18 @@ import androidx.recyclerview.widget.RecyclerView
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.adapter.FilteredStatusViewHolder
import com.keylesspalace.tusky.adapter.FollowRequestViewHolder
import com.keylesspalace.tusky.adapter.LoadMoreViewHolder
import com.keylesspalace.tusky.adapter.PlaceholderViewHolder
import com.keylesspalace.tusky.adapter.StatusBaseViewHolder
import com.keylesspalace.tusky.databinding.ItemFollowBinding
import com.keylesspalace.tusky.databinding.ItemFollowRequestBinding
import com.keylesspalace.tusky.databinding.ItemLoadMoreBinding
import com.keylesspalace.tusky.databinding.ItemModerationWarningNotificationBinding
import com.keylesspalace.tusky.databinding.ItemPlaceholderBinding
import com.keylesspalace.tusky.databinding.ItemReportNotificationBinding
import com.keylesspalace.tusky.databinding.ItemSeveredRelationshipNotificationBinding
import com.keylesspalace.tusky.databinding.ItemStatusFilteredBinding
import com.keylesspalace.tusky.databinding.ItemStatusNotificationBinding
import com.keylesspalace.tusky.databinding.ItemStatusPlaceholderBinding
import com.keylesspalace.tusky.databinding.ItemUnknownNotificationBinding
import com.keylesspalace.tusky.entity.Filter
import com.keylesspalace.tusky.entity.Notification
@ -80,16 +82,17 @@ class NotificationsPagingAdapter(
override fun getItemViewType(position: Int): Int {
return when (val notification = getItem(position)) {
is NotificationViewData.LoadMore -> VIEW_TYPE_LOAD_MORE
is NotificationViewData.Concrete -> {
when (notification.type) {
Notification.Type.Mention,
Notification.Type.Poll -> if (notification.statusViewData?.filterAction == Filter.Action.WARN) {
Notification.Type.Poll -> if (notification.statusViewData?.filter?.action == Filter.Action.WARN) {
VIEW_TYPE_STATUS_FILTERED
} else {
VIEW_TYPE_STATUS
}
Notification.Type.Status,
Notification.Type.Update -> if (notification.statusViewData?.filterAction == Filter.Action.WARN) {
Notification.Type.Update -> if (notification.statusViewData?.filter?.action == Filter.Action.WARN) {
VIEW_TYPE_STATUS_FILTERED
} else {
VIEW_TYPE_STATUS_NOTIFICATION
@ -105,13 +108,17 @@ class NotificationsPagingAdapter(
else -> VIEW_TYPE_UNKNOWN
}
}
else -> VIEW_TYPE_PLACEHOLDER
null -> VIEW_TYPE_PLACEHOLDER
}
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
val inflater = LayoutInflater.from(parent.context)
return when (viewType) {
VIEW_TYPE_PLACEHOLDER -> PlaceholderViewHolder(
ItemPlaceholderBinding.inflate(inflater, parent, false),
mode = PlaceholderViewHolder.Mode.NOTIFICATION
)
VIEW_TYPE_STATUS -> StatusViewHolder(
inflater.inflate(R.layout.item_status, parent, false),
statusListener,
@ -137,8 +144,8 @@ class NotificationsPagingAdapter(
statusListener,
true
)
VIEW_TYPE_PLACEHOLDER -> PlaceholderViewHolder(
ItemStatusPlaceholderBinding.inflate(inflater, parent, false),
VIEW_TYPE_LOAD_MORE -> LoadMoreViewHolder(
ItemLoadMoreBinding.inflate(inflater, parent, false),
statusListener
)
VIEW_TYPE_REPORT -> ReportNotificationViewHolder(
@ -169,24 +176,25 @@ class NotificationsPagingAdapter(
when (notification) {
is NotificationViewData.Concrete ->
(viewHolder as NotificationsViewHolder).bind(notification, payloads, statusDisplayOptions)
is NotificationViewData.Placeholder -> {
(viewHolder as PlaceholderViewHolder).setup(notification.isLoading)
is NotificationViewData.LoadMore -> {
(viewHolder as LoadMoreViewHolder).setup(notification.isLoading)
}
}
}
}
companion object {
private const val VIEW_TYPE_STATUS = 0
private const val VIEW_TYPE_STATUS_FILTERED = 1
private const val VIEW_TYPE_STATUS_NOTIFICATION = 2
private const val VIEW_TYPE_FOLLOW = 3
private const val VIEW_TYPE_FOLLOW_REQUEST = 4
private const val VIEW_TYPE_PLACEHOLDER = 5
private const val VIEW_TYPE_REPORT = 6
private const val VIEW_TYPE_SEVERED_RELATIONSHIP = 7
private const val VIEW_TYPE_MODERATION_WARNING = 8
private const val VIEW_TYPE_UNKNOWN = 9
private const val VIEW_TYPE_PLACEHOLDER = 0
private const val VIEW_TYPE_STATUS = 1
private const val VIEW_TYPE_STATUS_FILTERED = 2
private const val VIEW_TYPE_STATUS_NOTIFICATION = 3
private const val VIEW_TYPE_FOLLOW = 4
private const val VIEW_TYPE_FOLLOW_REQUEST = 5
private const val VIEW_TYPE_LOAD_MORE = 6
private const val VIEW_TYPE_REPORT = 7
private const val VIEW_TYPE_SEVERED_RELATIONSHIP = 8
private const val VIEW_TYPE_MODERATION_WARNING = 9
private const val VIEW_TYPE_UNKNOWN = 10
val NotificationsDifferCallback = object : DiffUtil.ItemCallback<NotificationViewData>() {
override fun areItemsTheSame(

4
app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsRemoteMediator.kt

@ -22,7 +22,7 @@ import androidx.paging.PagingState
import androidx.paging.RemoteMediator
import androidx.room.withTransaction
import com.keylesspalace.tusky.components.systemnotifications.toTypes
import com.keylesspalace.tusky.components.timeline.Placeholder
import com.keylesspalace.tusky.components.timeline.LoadMorePlaceholder
import com.keylesspalace.tusky.components.timeline.toEntity
import com.keylesspalace.tusky.components.timeline.util.ifExpected
import com.keylesspalace.tusky.db.AccountManager
@ -119,7 +119,7 @@ class NotificationsRemoteMediator(
to guarantee the placeholder has an id that exists on the server as not all
servers handle client generated ids as expected */
notificationsDao.insertNotification(
Placeholder(notifications.last().id, loading = false).toNotificationEntity(activeAccount.id)
LoadMorePlaceholder(notifications.last().id, loading = false).toNotificationEntity(activeAccount.id)
)
}
}

29
app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsViewModel.kt

@ -36,7 +36,7 @@ import com.keylesspalace.tusky.appstore.PreferenceChangedEvent
import com.keylesspalace.tusky.components.preference.PreferencesFragment.ReadingOrder
import com.keylesspalace.tusky.components.systemnotifications.NotificationChannelData
import com.keylesspalace.tusky.components.systemnotifications.toTypes
import com.keylesspalace.tusky.components.timeline.Placeholder
import com.keylesspalace.tusky.components.timeline.LoadMorePlaceholder
import com.keylesspalace.tusky.components.timeline.toEntity
import com.keylesspalace.tusky.components.timeline.util.ifExpected
import com.keylesspalace.tusky.components.timeline.viewmodel.TimelineViewModel
@ -117,7 +117,7 @@ class NotificationsViewModel @Inject constructor(
val translation = translations[notification.status?.serverId]
notification.toViewData(translation = translation)
}.filter { notificationViewData ->
shouldFilterStatus(notificationViewData) != Filter.Action.HIDE
shouldFilterStatus(notificationViewData)?.action != Filter.Action.HIDE
}
}
}
@ -131,7 +131,7 @@ class NotificationsViewModel @Inject constructor(
if (event is PreferenceChangedEvent) {
onPreferenceChanged(event.preferenceKey)
}
if (event is FilterUpdatedEvent && event.filterContext.contains(Filter.Kind.NOTIFICATIONS.kind)) {
if (event is FilterUpdatedEvent && event.filterContext.contains(Filter.Kind.NOTIFICATIONS)) {
filterModel.init(Filter.Kind.NOTIFICATIONS)
refreshTrigger.value += 1
}
@ -165,21 +165,21 @@ class NotificationsViewModel @Inject constructor(
}
}
private fun shouldFilterStatus(notificationViewData: NotificationViewData): Filter.Action {
private fun shouldFilterStatus(notificationViewData: NotificationViewData): Filter? {
return when ((notificationViewData as? NotificationViewData.Concrete)?.type) {
Notification.Type.Mention, Notification.Type.Poll, Notification.Type.Status, Notification.Type.Update -> {
val account = activeAccountFlow.value
notificationViewData.statusViewData?.let { statusViewData ->
if (statusViewData.status.account.id == account?.accountId) {
return Filter.Action.NONE
return null
}
statusViewData.filterAction = filterModel.shouldFilterStatus(statusViewData.actionable)
return statusViewData.filterAction
statusViewData.filter = filterModel.shouldFilterStatus(statusViewData.actionable)
return statusViewData.filter
}
Filter.Action.NONE
null
}
else -> Filter.Action.NONE
else -> null
}
}
@ -312,7 +312,7 @@ class NotificationsViewModel @Inject constructor(
val notificationsDao = db.notificationsDao()
notificationsDao.insertNotification(
Placeholder(placeholderId, loading = true).toNotificationEntity(
LoadMorePlaceholder(placeholderId, loading = true).toNotificationEntity(
accountId
)
)
@ -346,10 +346,7 @@ class NotificationsViewModel @Inject constructor(
return@launch
}
val account = activeAccountFlow.value
if (account == null) {
return@launch
}
val account = activeAccountFlow.value ?: return@launch
val statusDao = db.timelineStatusDao()
val accountDao = db.timelineAccountDao()
@ -404,7 +401,7 @@ class NotificationsViewModel @Inject constructor(
ReadingOrder.NEWEST_FIRST -> notifications.last().id
}
notificationsDao.insertNotification(
Placeholder(
LoadMorePlaceholder(
idToConvert,
loading = false
).toNotificationEntity(accountId)
@ -424,7 +421,7 @@ class NotificationsViewModel @Inject constructor(
val activeAccount = accountManager.activeAccount!!
db.notificationsDao()
.insertNotification(
Placeholder(placeholderId, loading = false).toNotificationEntity(activeAccount.id)
LoadMorePlaceholder(placeholderId, loading = false).toNotificationEntity(activeAccount.id)
)
}

45
app/src/main/java/com/keylesspalace/tusky/components/notifications/requests/details/NotificationRequestDetailsFragment.kt

@ -28,6 +28,7 @@ import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.SimpleItemAnimator
import at.connyduck.calladapter.networkresult.onFailure
import at.connyduck.sparkbutton.SparkButton
import com.google.android.material.snackbar.BaseTransientBottomBar.LENGTH_LONG
import com.google.android.material.snackbar.Snackbar
import com.keylesspalace.tusky.R
@ -47,6 +48,8 @@ import com.keylesspalace.tusky.util.openLink
import com.keylesspalace.tusky.util.show
import com.keylesspalace.tusky.util.viewBinding
import com.keylesspalace.tusky.util.visible
import com.keylesspalace.tusky.view.ConfirmationBottomSheet.Companion.confirmFavourite
import com.keylesspalace.tusky.view.ConfirmationBottomSheet.Companion.confirmReblog
import com.keylesspalace.tusky.viewdata.AttachmentViewData
import com.keylesspalace.tusky.viewdata.TranslationViewData
import dagger.hilt.android.AndroidEntryPoint
@ -67,6 +70,8 @@ class NotificationRequestDetailsFragment : SFragment(R.layout.fragment_notificat
private var adapter: NotificationsPagingAdapter? = null
private var buttonToAnimate: SparkButton? = null
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
@ -115,8 +120,6 @@ class NotificationRequestDetailsFragment : SFragment(R.layout.fragment_notificat
} else {
CardViewMode.NONE
},
confirmReblogs = preferences.getBoolean(PrefKeys.CONFIRM_REBLOGS, true),
confirmFavourites = preferences.getBoolean(PrefKeys.CONFIRM_FAVOURITES, false),
hideStats = preferences.getBoolean(PrefKeys.WELLBEING_HIDE_STATS_POSTS, false),
animateEmojis = preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false),
showStatsInline = preferences.getBoolean(PrefKeys.SHOW_STATS_INLINE, false),
@ -161,9 +164,23 @@ class NotificationRequestDetailsFragment : SFragment(R.layout.fragment_notificat
viewModel.remove(notification)
}
override fun onReblog(reblog: Boolean, position: Int, visibility: Status.Visibility) {
override fun onReblog(reblog: Boolean, position: Int, visibility: Status.Visibility?, button: SparkButton?) {
val status = adapter?.peek(position)?.asStatusOrNull() ?: return
viewModel.reblog(reblog, status, visibility)
buttonToAnimate = button
if (reblog && visibility == null) {
confirmReblog(preferences) { visibility ->
viewModel.reblog(true, status, visibility)
buttonToAnimate?.playAnimation()
buttonToAnimate?.isChecked = true
}
} else {
viewModel.reblog(reblog, status, visibility ?: Status.Visibility.PUBLIC)
if (reblog) {
buttonToAnimate?.playAnimation()
}
buttonToAnimate?.isChecked = reblog
}
}
override val onMoreTranslate: ((Boolean, Int) -> Unit)?
@ -175,9 +192,20 @@ class NotificationRequestDetailsFragment : SFragment(R.layout.fragment_notificat
}
}
override fun onFavourite(favourite: Boolean, position: Int) {
override fun onFavourite(favourite: Boolean, position: Int, button: SparkButton?) {
val status = adapter?.peek(position)?.asStatusOrNull() ?: return
viewModel.favorite(favourite, status)
buttonToAnimate = button
if (favourite) {
confirmFavourite(preferences) {
viewModel.favorite(true, status)
buttonToAnimate?.playAnimation()
buttonToAnimate?.isChecked = true
}
} else {
viewModel.favorite(false, status)
buttonToAnimate?.isChecked = false
}
}
override fun onBookmark(bookmark: Boolean, position: Int) {
@ -285,13 +313,14 @@ class NotificationRequestDetailsFragment : SFragment(R.layout.fragment_notificat
// not needed, blocking via the more menu on statuses is handled in SFragment
}
override fun onRespondToFollowRequest(accept: Boolean, id: String, position: Int) {
override fun onRespondToFollowRequest(accept: Boolean, accountIdRequestingFollow: String, position: Int) {
val notification = adapter?.peek(position) ?: return
viewModel.respondToFollowRequest(accept, accountId = id, notification = notification)
viewModel.respondToFollowRequest(accept, accountId = accountIdRequestingFollow, notification = notification)
}
override fun onDestroyView() {
adapter = null
buttonToAnimate = null
super.onDestroyView()
}

6
app/src/main/java/com/keylesspalace/tusky/components/notifications/requests/details/NotificationRequestDetailsRemoteMediator.kt

@ -20,6 +20,7 @@ import androidx.paging.LoadType
import androidx.paging.PagingState
import androidx.paging.RemoteMediator
import com.keylesspalace.tusky.components.notifications.toViewData
import com.keylesspalace.tusky.entity.Filter
import com.keylesspalace.tusky.entity.Notification
import com.keylesspalace.tusky.util.HttpHeaderLink
import com.keylesspalace.tusky.viewdata.NotificationViewData
@ -70,9 +71,10 @@ class NotificationRequestDetailsRemoteMediator(
val alwaysOpenSpoiler = viewModel.accountManager.activeAccount?.alwaysOpenSpoiler == false
val notificationData = notifications.map { notification ->
notification.toViewData(
isShowingContent = alwaysShowSensitiveMedia,
isShowingContent = notification.status?.shouldShowContent(alwaysShowSensitiveMedia, Filter.Kind.NOTIFICATIONS) ?: true,
isExpanded = alwaysOpenSpoiler,
true
isCollapsed = true,
filter = notification.status?.getApplicableFilter(Filter.Kind.NOTIFICATIONS),
)
}

3
app/src/main/java/com/keylesspalace/tusky/components/preference/PreferencesActivity.kt

@ -41,6 +41,7 @@ import com.keylesspalace.tusky.util.getNonNullString
import com.keylesspalace.tusky.util.setAppNightMode
import com.keylesspalace.tusky.util.startActivityWithSlideInAnimation
import dagger.hilt.android.AndroidEntryPoint
import de.c1710.filemojicompat_ui.helpers.EMOJI_PREFERENCE
import javax.inject.Inject
import kotlinx.coroutines.launch
@ -159,7 +160,7 @@ class PreferencesActivity :
}
PrefKeys.STATUS_TEXT_SIZE, PrefKeys.ABSOLUTE_TIME_VIEW, PrefKeys.SHOW_BOT_OVERLAY, PrefKeys.ANIMATE_GIF_AVATARS, PrefKeys.USE_BLURHASH,
PrefKeys.SHOW_SELF_USERNAME, PrefKeys.SHOW_CARDS_IN_TIMELINES, PrefKeys.CONFIRM_REBLOGS, PrefKeys.CONFIRM_FAVOURITES,
PrefKeys.ENABLE_SWIPE_FOR_TABS, PrefKeys.MAIN_NAV_POSITION, PrefKeys.HIDE_TOP_TOOLBAR, PrefKeys.SHOW_STATS_INLINE -> {
EMOJI_PREFERENCE, PrefKeys.ENABLE_SWIPE_FOR_TABS, PrefKeys.MAIN_NAV_POSITION, PrefKeys.HIDE_TOP_TOOLBAR, PrefKeys.SHOW_STATS_INLINE -> {
restartActivitiesOnBackPressedCallback.isEnabled = true
}
}

1
app/src/main/java/com/keylesspalace/tusky/components/preference/PreferencesFragment.kt

@ -21,7 +21,6 @@ import androidx.annotation.DrawableRes
import androidx.lifecycle.lifecycleScope
import androidx.preference.Preference
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.components.preference.PreferencesFragment.ReadingOrder.valueOf
import com.keylesspalace.tusky.components.systemnotifications.NotificationChannelData
import com.keylesspalace.tusky.db.AccountManager
import com.keylesspalace.tusky.settings.AppTheme

2
app/src/main/java/com/keylesspalace/tusky/components/report/ReportViewModel.kt

@ -87,7 +87,7 @@ class ReportViewModel @Inject constructor(
.map { pagingData ->
/* TODO: refactor reports to use the isShowingContent / isExpanded / isCollapsed attributes from StatusViewData.Concrete
instead of StatusViewState */
pagingData.map { status -> status.toViewData(false, false, false) }
pagingData.map { status -> status.toViewData(false, false, false, filter = null) }
}
.cachedIn(viewModelScope)

4
app/src/main/java/com/keylesspalace/tusky/components/report/fragments/ReportStatusesFragment.kt

@ -83,7 +83,7 @@ class ReportStatusesFragment :
when (status.attachments[idx].type) {
Attachment.Type.GIFV, Attachment.Type.VIDEO, Attachment.Type.IMAGE, Attachment.Type.AUDIO -> {
val attachments = AttachmentViewData.list(status)
val intent = ViewMediaActivity.newIntent(context, attachments, idx)
val intent = ViewMediaActivity.newIntent(requireContext(), attachments, idx)
if (v != null) {
val url = status.attachments[idx].url
ViewCompat.setTransitionName(v, url)
@ -147,8 +147,6 @@ class ReportStatusesFragment :
showBotOverlay = false,
useBlurhash = preferences.getBoolean(PrefKeys.USE_BLURHASH, true),
cardViewMode = CardViewMode.NONE,
confirmReblogs = preferences.getBoolean(PrefKeys.CONFIRM_REBLOGS, true),
confirmFavourites = preferences.getBoolean(PrefKeys.CONFIRM_FAVOURITES, false),
hideStats = preferences.getBoolean(PrefKeys.WELLBEING_HIDE_STATS_POSTS, false),
animateEmojis = preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false),
showStatsInline = preferences.getBoolean(PrefKeys.SHOW_STATS_INLINE, false),

14
app/src/main/java/com/keylesspalace/tusky/components/search/SearchViewModel.kt

@ -30,6 +30,7 @@ import com.keylesspalace.tusky.components.search.adapter.SearchPagingSourceFacto
import com.keylesspalace.tusky.db.AccountManager
import com.keylesspalace.tusky.db.entity.AccountEntity
import com.keylesspalace.tusky.entity.DeletedStatus
import com.keylesspalace.tusky.entity.Filter
import com.keylesspalace.tusky.entity.Status
import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.usecase.TimelineCases
@ -70,9 +71,10 @@ class SearchViewModel @Inject constructor(
SearchPagingSourceFactory(mastodonApi, SearchType.Status, loadedStatuses) {
it.statuses.map { status ->
status.toViewData(
isShowingContent = alwaysShowSensitiveMedia || !status.actionableStatus.sensitive,
isShowingContent = status.shouldShowContent(alwaysShowSensitiveMedia, Filter.Kind.PUBLIC),
isExpanded = alwaysOpenSpoiler,
isCollapsed = true
isCollapsed = true,
filter = status.getApplicableFilter(Filter.Kind.PUBLIC),
)
}.apply {
loadedStatuses.addAll(this)
@ -121,9 +123,9 @@ class SearchViewModel @Inject constructor(
hashtagsPagingSourceFactory.newSearch(query)
}
fun removeItem(statusViewData: StatusViewData.Concrete) {
fun removeItem(statusViewData: StatusViewData.Concrete, deleteMedia: Boolean) {
viewModelScope.launch {
if (timelineCases.delete(statusViewData.id).isSuccess) {
if (timelineCases.delete(statusViewData.id, deleteMedia).isSuccess) {
if (loadedStatuses.remove(statusViewData)) {
statusesPagingSourceFactory.invalidate()
}
@ -207,9 +209,9 @@ class SearchViewModel @Inject constructor(
}
}
fun deleteStatusAsync(id: String): Deferred<NetworkResult<DeletedStatus>> {
fun deleteStatusAsync(id: String, deleteMedia: Boolean): Deferred<NetworkResult<DeletedStatus>> {
return viewModelScope.async {
timelineCases.delete(id)
timelineCases.delete(id, deleteMedia)
}
}

69
app/src/main/java/com/keylesspalace/tusky/components/search/fragments/SearchStatusesFragment.kt

@ -19,7 +19,6 @@ import android.Manifest
import android.app.DownloadManager
import android.content.Intent
import android.content.SharedPreferences
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.os.Environment
@ -30,6 +29,7 @@ import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.widget.PopupMenu
import androidx.core.app.ActivityOptionsCompat
import androidx.core.content.getSystemService
import androidx.core.net.toUri
import androidx.core.view.ViewCompat
import androidx.lifecycle.lifecycleScope
import androidx.paging.PagingData
@ -38,6 +38,7 @@ import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.LinearLayoutManager
import at.connyduck.calladapter.networkresult.fold
import at.connyduck.calladapter.networkresult.onFailure
import at.connyduck.sparkbutton.SparkButton
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.snackbar.Snackbar
import com.keylesspalace.tusky.R
@ -61,6 +62,8 @@ import com.keylesspalace.tusky.util.copyToClipboard
import com.keylesspalace.tusky.util.openLink
import com.keylesspalace.tusky.util.startActivityWithSlideInAnimation
import com.keylesspalace.tusky.util.updateRelativeTimePeriodically
import com.keylesspalace.tusky.view.ConfirmationBottomSheet.Companion.confirmFavourite
import com.keylesspalace.tusky.view.ConfirmationBottomSheet.Companion.confirmReblog
import com.keylesspalace.tusky.view.showMuteAccountDialog
import com.keylesspalace.tusky.viewdata.AttachmentViewData
import com.keylesspalace.tusky.viewdata.StatusViewData
@ -97,6 +100,8 @@ class SearchStatusesFragment : SearchFragment<StatusViewData.Concrete>(), Status
pendingMediaDownloads = null
}
private var buttonToAnimate: SparkButton? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
pendingMediaDownloads = savedInstanceState?.getStringArrayList(PENDING_MEDIA_DOWNLOADS_STATE_KEY)
@ -125,8 +130,6 @@ class SearchStatusesFragment : SearchFragment<StatusViewData.Concrete>(), Status
showBotOverlay = preferences.getBoolean(PrefKeys.SHOW_BOT_OVERLAY, true),
useBlurhash = preferences.getBoolean(PrefKeys.USE_BLURHASH, true),
cardViewMode = CardViewMode.NONE,
confirmReblogs = preferences.getBoolean(PrefKeys.CONFIRM_REBLOGS, true),
confirmFavourites = preferences.getBoolean(PrefKeys.CONFIRM_FAVOURITES, false),
hideStats = preferences.getBoolean(PrefKeys.WELLBEING_HIDE_STATS_POSTS, false),
animateEmojis = preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false),
showStatsInline = preferences.getBoolean(PrefKeys.SHOW_STATS_INLINE, false),
@ -156,6 +159,11 @@ class SearchStatusesFragment : SearchFragment<StatusViewData.Concrete>(), Status
return adapter
}
override fun onDestroyView() {
buttonToAnimate = null
super.onDestroyView()
}
override fun onRefresh() {
viewModel.clearStatusCache()
super.onRefresh()
@ -173,9 +181,18 @@ class SearchStatusesFragment : SearchFragment<StatusViewData.Concrete>(), Status
}
}
override fun onFavourite(favourite: Boolean, position: Int) {
adapter?.peek(position)?.let { status ->
viewModel.favorite(status, favourite)
override fun onFavourite(favourite: Boolean, position: Int, button: SparkButton?) {
val status = adapter?.peek(position)?.asStatusOrNull() ?: return
if (favourite) {
confirmFavourite(preferences) {
viewModel.favorite(status, true)
buttonToAnimate?.playAnimation()
buttonToAnimate?.isChecked = true
}
} else {
viewModel.favorite(status, false)
buttonToAnimate?.isChecked = false
}
}
@ -197,7 +214,7 @@ class SearchStatusesFragment : SearchFragment<StatusViewData.Concrete>(), Status
Attachment.Type.GIFV, Attachment.Type.VIDEO, Attachment.Type.IMAGE, Attachment.Type.AUDIO -> {
val attachments = AttachmentViewData.list(status)
val intent = ViewMediaActivity.newIntent(
context,
requireContext(),
attachments,
attachmentIndex
)
@ -265,15 +282,29 @@ class SearchStatusesFragment : SearchFragment<StatusViewData.Concrete>(), Status
override fun clearWarningAction(position: Int) {}
private fun removeItem(position: Int) {
private fun removeItem(position: Int, deleteMedia: Boolean) {
adapter?.peek(position)?.let {
viewModel.removeItem(it)
viewModel.removeItem(it, deleteMedia)
}
}
override fun onReblog(reblog: Boolean, position: Int, visibility: Status.Visibility) {
override fun onReblog(reblog: Boolean, position: Int, visibility: Status.Visibility?, button: SparkButton?) {
adapter?.peek(position)?.let { status ->
viewModel.reblog(status, reblog, visibility)
buttonToAnimate = button
if (reblog && visibility == null) {
confirmReblog(preferences) { visibility ->
viewModel.reblog(status, true, visibility)
buttonToAnimate?.playAnimation()
buttonToAnimate?.isChecked = true
}
} else {
viewModel.reblog(status, reblog, visibility ?: Status.Visibility.PUBLIC)
if (reblog) {
buttonToAnimate?.playAnimation()
}
buttonToAnimate?.isChecked = false
}
}
}
@ -453,12 +484,12 @@ class SearchStatusesFragment : SearchFragment<StatusViewData.Concrete>(), Status
}
R.id.status_unreblog_private -> {
onReblog(false, position)
onReblog(false, position, Status.Visibility.PRIVATE)
return@setOnMenuItemClickListener true
}
R.id.status_reblog_private -> {
onReblog(true, position)
onReblog(true, position, Status.Visibility.PRIVATE)
return@setOnMenuItemClickListener true
}
@ -523,7 +554,7 @@ class SearchStatusesFragment : SearchFragment<StatusViewData.Concrete>(), Status
private fun accountIsInMentions(account: AccountEntity?, mentions: List<Mention>): Boolean {
return mentions.firstOrNull {
account?.username == it.username && account.domain == Uri.parse(it.url)?.host
account?.username == it.username && account.domain == it.url.toUri().host
} != null
}
@ -544,7 +575,7 @@ class SearchStatusesFragment : SearchFragment<StatusViewData.Concrete>(), Status
val downloadManager: DownloadManager = requireContext().getSystemService()!!
for (url in mediaUrls) {
val uri = Uri.parse(url)
val uri = url.toUri()
val request = DownloadManager.Request(uri)
request.setDestinationInExternalPublicDir(
Environment.DIRECTORY_DOWNLOADS,
@ -578,8 +609,8 @@ class SearchStatusesFragment : SearchFragment<StatusViewData.Concrete>(), Status
MaterialAlertDialogBuilder(it)
.setMessage(R.string.dialog_delete_post_warning)
.setPositiveButton(android.R.string.ok) { _, _ ->
viewModel.deleteStatusAsync(id)
removeItem(position)
viewModel.deleteStatusAsync(id, true)
removeItem(position, true)
}
.setNegativeButton(android.R.string.cancel, null)
.show()
@ -592,9 +623,9 @@ class SearchStatusesFragment : SearchFragment<StatusViewData.Concrete>(), Status
.setMessage(R.string.dialog_redraft_post_warning)
.setPositiveButton(android.R.string.ok) { _, _ ->
viewLifecycleOwner.lifecycleScope.launch {
viewModel.deleteStatusAsync(id).await().fold(
viewModel.deleteStatusAsync(id, false).await().fold(
{ deletedStatus ->
removeItem(position)
removeItem(position, false)
val redraftStatus = if (deletedStatus.isEmpty) {
status.toDeletedStatus()

39
app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt

@ -35,6 +35,7 @@ import androidx.recyclerview.widget.RecyclerView
import androidx.recyclerview.widget.SimpleItemAnimator
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout.OnRefreshListener
import at.connyduck.calladapter.networkresult.onFailure
import at.connyduck.sparkbutton.SparkButton
import at.connyduck.sparkbutton.helpers.Utils
import com.google.android.material.snackbar.Snackbar
import com.keylesspalace.tusky.R
@ -65,6 +66,8 @@ import com.keylesspalace.tusky.util.startActivityWithSlideInAnimation
import com.keylesspalace.tusky.util.unsafeLazy
import com.keylesspalace.tusky.util.updateRelativeTimePeriodically
import com.keylesspalace.tusky.util.viewBinding
import com.keylesspalace.tusky.view.ConfirmationBottomSheet.Companion.confirmFavourite
import com.keylesspalace.tusky.view.ConfirmationBottomSheet.Companion.confirmReblog
import com.keylesspalace.tusky.viewdata.AttachmentViewData
import com.keylesspalace.tusky.viewdata.StatusViewData
import com.keylesspalace.tusky.viewdata.TranslationViewData
@ -137,6 +140,8 @@ class TimelineFragment :
/** The user's preferred reading order */
private lateinit var readingOrder: ReadingOrder
private var buttonToAnimate: SparkButton? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@ -184,8 +189,6 @@ class TimelineFragment :
} else {
CardViewMode.NONE
},
confirmReblogs = preferences.getBoolean(PrefKeys.CONFIRM_REBLOGS, true),
confirmFavourites = preferences.getBoolean(PrefKeys.CONFIRM_FAVOURITES, false),
hideStats = preferences.getBoolean(PrefKeys.WELLBEING_HIDE_STATS_POSTS, false),
animateEmojis = preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false),
showStatsInline = preferences.getBoolean(PrefKeys.SHOW_STATS_INLINE, false),
@ -296,6 +299,7 @@ class TimelineFragment :
override fun onDestroyView() {
// Clear the adapter to prevent leaking the View
adapter = null
buttonToAnimate = null
super.onDestroyView()
}
@ -403,9 +407,23 @@ class TimelineFragment :
super.reply(status.status)
}
override fun onReblog(reblog: Boolean, position: Int, visibility: Status.Visibility) {
override fun onReblog(reblog: Boolean, position: Int, visibility: Status.Visibility?, button: SparkButton?) {
val status = adapter?.peek(position)?.asStatusOrNull() ?: return
viewModel.reblog(reblog, status, visibility)
buttonToAnimate = button
if (reblog && visibility == null) {
confirmReblog(preferences) { visibility ->
viewModel.reblog(true, status, visibility)
buttonToAnimate?.playAnimation()
buttonToAnimate?.isChecked = true
}
} else {
viewModel.reblog(reblog, status, visibility ?: Status.Visibility.PUBLIC)
if (reblog) {
buttonToAnimate?.playAnimation()
}
buttonToAnimate?.isChecked = reblog
}
}
private fun onTranslate(position: Int) {
@ -427,9 +445,18 @@ class TimelineFragment :
viewModel.untranslate(status)
}
override fun onFavourite(favourite: Boolean, position: Int) {
override fun onFavourite(favourite: Boolean, position: Int, button: SparkButton?) {
val status = adapter?.peek(position)?.asStatusOrNull() ?: return
viewModel.favorite(favourite, status)
buttonToAnimate = button
if (favourite) {
confirmFavourite(preferences) {
viewModel.favorite(true, status)
buttonToAnimate?.playAnimation()
}
} else {
viewModel.favorite(false, status)
}
}
override fun onBookmark(bookmark: Boolean, position: Int) {

50
app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelinePagingAdapter.kt

@ -22,11 +22,13 @@ import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.RecyclerView
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.adapter.FilteredStatusViewHolder
import com.keylesspalace.tusky.adapter.LoadMoreViewHolder
import com.keylesspalace.tusky.adapter.PlaceholderViewHolder
import com.keylesspalace.tusky.adapter.StatusBaseViewHolder
import com.keylesspalace.tusky.adapter.StatusViewHolder
import com.keylesspalace.tusky.databinding.ItemLoadMoreBinding
import com.keylesspalace.tusky.databinding.ItemPlaceholderBinding
import com.keylesspalace.tusky.databinding.ItemStatusFilteredBinding
import com.keylesspalace.tusky.databinding.ItemStatusPlaceholderBinding
import com.keylesspalace.tusky.entity.Filter
import com.keylesspalace.tusky.interfaces.StatusActionListener
import com.keylesspalace.tusky.util.StatusDisplayOptions
@ -49,23 +51,29 @@ class TimelinePagingAdapter(
stateRestorationPolicy = StateRestorationPolicy.PREVENT_WHEN_EMPTY
}
override fun onCreateViewHolder(viewGroup: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
val inflater = LayoutInflater.from(viewGroup.context)
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
val inflater = LayoutInflater.from(parent.context)
return when (viewType) {
VIEW_TYPE_PLACEHOLDER -> {
PlaceholderViewHolder(
ItemPlaceholderBinding.inflate(inflater, parent, false),
mode = PlaceholderViewHolder.Mode.STATUS
)
}
VIEW_TYPE_STATUS_FILTERED -> {
FilteredStatusViewHolder(
ItemStatusFilteredBinding.inflate(inflater, viewGroup, false),
ItemStatusFilteredBinding.inflate(inflater, parent, false),
statusListener
)
}
VIEW_TYPE_PLACEHOLDER -> {
PlaceholderViewHolder(
ItemStatusPlaceholderBinding.inflate(inflater, viewGroup, false),
VIEW_TYPE_LOAD_MORE -> {
LoadMoreViewHolder(
ItemLoadMoreBinding.inflate(inflater, parent, false),
statusListener
)
}
else -> {
StatusViewHolder(inflater.inflate(R.layout.item_status, viewGroup, false))
StatusViewHolder(inflater.inflate(R.layout.item_status, parent, false))
}
}
}
@ -80,11 +88,11 @@ class TimelinePagingAdapter(
payloads: List<Any>
) {
val viewData = getItem(position)
if (viewData is StatusViewData.Placeholder) {
val holder = viewHolder as PlaceholderViewHolder
if (viewData is StatusViewData.LoadMore) {
val holder = viewHolder as LoadMoreViewHolder
holder.setup(viewData.isLoading)
} else if (viewData is StatusViewData.Concrete) {
if (viewData.filterAction == Filter.Action.WARN) {
if (viewData.filter?.action == Filter.Action.WARN) {
val holder = viewHolder as FilteredStatusViewHolder
holder.bind(viewData)
} else {
@ -102,21 +110,21 @@ class TimelinePagingAdapter(
override fun getItemViewType(position: Int): Int {
val viewData = getItem(position)
return if (viewData is StatusViewData.Placeholder) {
VIEW_TYPE_PLACEHOLDER
} else if (viewData?.filterAction == Filter.Action.WARN) {
VIEW_TYPE_STATUS_FILTERED
} else {
VIEW_TYPE_STATUS
return when {
viewData == null -> VIEW_TYPE_PLACEHOLDER
viewData is StatusViewData.LoadMore -> VIEW_TYPE_LOAD_MORE
viewData.filter?.action == Filter.Action.WARN -> VIEW_TYPE_STATUS_FILTERED
else -> VIEW_TYPE_STATUS
}
}
companion object {
private const val VIEW_TYPE_STATUS = 0
private const val VIEW_TYPE_STATUS_FILTERED = 1
private const val VIEW_TYPE_PLACEHOLDER = 2
private const val VIEW_TYPE_PLACEHOLDER = 0
private const val VIEW_TYPE_STATUS = 1
private const val VIEW_TYPE_STATUS_FILTERED = 2
private const val VIEW_TYPE_LOAD_MORE = 3
val TimelineDifferCallback = object : DiffUtil.ItemCallback<StatusViewData>() {
private val TimelineDifferCallback = object : DiffUtil.ItemCallback<StatusViewData>() {
override fun areItemsTheSame(
oldItem: StatusViewData,
newItem: StatusViewData

15
app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineTypeMappers.kt

@ -19,13 +19,14 @@ import com.keylesspalace.tusky.db.entity.HomeTimelineData
import com.keylesspalace.tusky.db.entity.HomeTimelineEntity
import com.keylesspalace.tusky.db.entity.TimelineAccountEntity
import com.keylesspalace.tusky.db.entity.TimelineStatusEntity
import com.keylesspalace.tusky.entity.Filter
import com.keylesspalace.tusky.entity.Status
import com.keylesspalace.tusky.entity.TimelineAccount
import com.keylesspalace.tusky.viewdata.StatusViewData
import com.keylesspalace.tusky.viewdata.TranslationViewData
import java.util.Date
data class Placeholder(
data class LoadMorePlaceholder(
val id: String,
val loading: Boolean
)
@ -59,7 +60,7 @@ fun TimelineAccountEntity.toAccount(): TimelineAccount {
)
}
fun Placeholder.toEntity(tuskyAccountId: Long): HomeTimelineEntity {
fun LoadMorePlaceholder.toEntity(tuskyAccountId: Long): HomeTimelineEntity {
return HomeTimelineEntity(
id = this.id,
tuskyAccountId = tuskyAccountId,
@ -143,9 +144,13 @@ fun TimelineStatusEntity.toStatus(
filtered = filtered,
)
fun HomeTimelineData.toViewData(isDetailed: Boolean = false, translation: TranslationViewData? = null): StatusViewData {
fun HomeTimelineData.toViewData(
isDetailed: Boolean = false,
translation: TranslationViewData? = null,
filter: Filter? = null,
): StatusViewData {
if (this.account == null || this.status == null) {
return StatusViewData.Placeholder(this.id, loading)
return StatusViewData.LoadMore(this.id, loading)
}
val originalStatus = status.toStatus(account)
@ -195,5 +200,5 @@ fun HomeTimelineData.toViewData(isDetailed: Boolean = false, translation: Transl
isDetailed = isDetailed,
repliedToAccount = repliedToAccount?.toAccount(),
translation = translation,
)
).apply { this.filter = filter }
}

6
app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/CachedTimelineRemoteMediator.kt

@ -21,7 +21,7 @@ import androidx.paging.LoadType
import androidx.paging.PagingState
import androidx.paging.RemoteMediator
import androidx.room.withTransaction
import com.keylesspalace.tusky.components.timeline.Placeholder
import com.keylesspalace.tusky.components.timeline.LoadMorePlaceholder
import com.keylesspalace.tusky.components.timeline.toEntity
import com.keylesspalace.tusky.components.timeline.util.ifExpected
import com.keylesspalace.tusky.db.AppDatabase
@ -113,7 +113,7 @@ class CachedTimelineRemoteMediator(
to guarantee the placeholder has an id that exists on the server as not all
servers handle client generated ids as expected */
timelineDao.insertHomeTimelineItem(
Placeholder(statuses.last().id, loading = false).toEntity(activeAccount.id)
LoadMorePlaceholder(statuses.last().id, loading = false).toEntity(activeAccount.id)
)
}
}
@ -161,7 +161,7 @@ class CachedTimelineRemoteMediator(
}
val expanded = oldStatus?.expanded ?: activeAccount.alwaysOpenSpoiler
val contentShowing = oldStatus?.contentShowing ?: (activeAccount.alwaysShowSensitiveMedia || !status.actionableStatus.sensitive)
val contentShowing = oldStatus?.contentShowing ?: status.shouldShowContent(activeAccount.alwaysShowSensitiveMedia, viewModel.kind.toFilterKind())
val contentCollapsed = oldStatus?.contentCollapsed != false
statusDao.insert(

20
app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/CachedTimelineViewModel.kt

@ -32,8 +32,9 @@ import at.connyduck.calladapter.networkresult.onFailure
import com.keylesspalace.tusky.appstore.EventHub
import com.keylesspalace.tusky.components.preference.PreferencesFragment.ReadingOrder.NEWEST_FIRST
import com.keylesspalace.tusky.components.preference.PreferencesFragment.ReadingOrder.OLDEST_FIRST
import com.keylesspalace.tusky.components.timeline.Placeholder
import com.keylesspalace.tusky.components.timeline.LoadMorePlaceholder
import com.keylesspalace.tusky.components.timeline.toEntity
import com.keylesspalace.tusky.components.timeline.toStatus
import com.keylesspalace.tusky.components.timeline.toViewData
import com.keylesspalace.tusky.components.timeline.util.ifExpected
import com.keylesspalace.tusky.db.AccountManager
@ -100,12 +101,15 @@ class CachedTimelineViewModel @Inject constructor(
.combine(translations) { pagingData, translations ->
pagingData.map { timelineData ->
val translation = translations[timelineData.status?.serverId]
val status = timelineData.account?.let { timelineData.status?.toStatus(it) }
val filter = status?.let { shouldFilterStatus(it) }
timelineData.toViewData(
isDetailed = false,
translation = translation
translation = translation,
filter = filter,
)
}.filter { statusViewData ->
shouldFilterStatus(statusViewData) != Filter.Action.HIDE
statusViewData.filter?.action != Filter.Action.HIDE
}
}
.flowOn(Dispatchers.Default)
@ -149,7 +153,7 @@ class CachedTimelineViewModel @Inject constructor(
val accountDao = db.timelineAccountDao()
timelineDao.insertHomeTimelineItem(
Placeholder(placeholderId, loading = true).toEntity(tuskyAccountId = accountId)
LoadMorePlaceholder(placeholderId, loading = true).toEntity(tuskyAccountId = accountId)
)
val (idAbovePlaceholder, idBelowPlaceholder) = db.withTransaction {
@ -207,8 +211,8 @@ class CachedTimelineViewModel @Inject constructor(
status.actionableStatus.toEntity(
tuskyAccountId = accountId,
expanded = account.alwaysOpenSpoiler,
contentShowing = account.alwaysShowSensitiveMedia || !status.actionableStatus.sensitive,
contentCollapsed = true
contentShowing = status.shouldShowContent(account.alwaysShowSensitiveMedia, kind.toFilterKind()),
contentCollapsed = true,
)
)
timelineDao.insertHomeTimelineItem(
@ -236,7 +240,7 @@ class CachedTimelineViewModel @Inject constructor(
NEWEST_FIRST -> statuses.last().id
}
timelineDao.insertHomeTimelineItem(
Placeholder(
LoadMorePlaceholder(
idToConvert,
loading = false
).toEntity(accountId)
@ -255,7 +259,7 @@ class CachedTimelineViewModel @Inject constructor(
Log.w(TAG, "failed loading statuses", e)
val activeAccount = accountManager.activeAccount!!
db.timelineDao()
.insertHomeTimelineItem(Placeholder(placeholderId, loading = false).toEntity(activeAccount.id))
.insertHomeTimelineItem(LoadMorePlaceholder(placeholderId, loading = false).toEntity(activeAccount.id))
}
override fun fullReload() {

8
app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelineRemoteMediator.kt

@ -74,14 +74,16 @@ class NetworkTimelineRemoteMediator(
s.asStatusOrNull()?.id == status.id
}?.asStatusOrNull()
val contentShowing = oldStatus?.isShowingContent ?: (activeAccount.alwaysShowSensitiveMedia || !status.actionableStatus.sensitive)
val filter = oldStatus?.filter ?: status.getApplicableFilter(viewModel.kind.toFilterKind())
val contentShowing = oldStatus?.isShowingContent ?: status.shouldShowContent(activeAccount.alwaysShowSensitiveMedia, viewModel.kind.toFilterKind())
val expanded = oldStatus?.isExpanded ?: activeAccount.alwaysOpenSpoiler
val contentCollapsed = oldStatus?.isCollapsed != false
status.toViewData(
isShowingContent = contentShowing,
isExpanded = expanded,
isCollapsed = contentCollapsed
isCollapsed = contentCollapsed,
filter = filter,
)
}
@ -101,7 +103,7 @@ class NetworkTimelineRemoteMediator(
viewModel.statusData.addAll(0, data)
if (insertPlaceholder) {
viewModel.statusData[statuses.size - 1] = StatusViewData.Placeholder(statuses.last().id, false)
viewModel.statusData[statuses.size - 1] = StatusViewData.LoadMore(statuses.last().id, false)
}
} else {
val linkHeader = statusResponse.headers()["Link"]

21
app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelineViewModel.kt

@ -102,7 +102,9 @@ class NetworkTimelineViewModel @Inject constructor(
).flow
.map { pagingData ->
pagingData.filter(Dispatchers.Default.asExecutor()) { statusViewData ->
shouldFilterStatus(statusViewData) != Filter.Action.HIDE
statusViewData.asStatusOrNull()?.actionable?.let {
shouldFilterStatus(it)?.action != Filter.Action.HIDE
} ?: true
}
}
.flowOn(Dispatchers.Default)
@ -198,9 +200,9 @@ class NetworkTimelineViewModel @Inject constructor(
viewModelScope.launch {
try {
val placeholderIndex =
statusData.indexOfFirst { it is StatusViewData.Placeholder && it.id == placeholderId }
statusData.indexOfFirst { it is StatusViewData.LoadMore && it.id == placeholderId }
statusData[placeholderIndex] =
StatusViewData.Placeholder(placeholderId, isLoading = true)
StatusViewData.LoadMore(placeholderId, isLoading = true)
val idAbovePlaceholder = statusData.getOrNull(placeholderIndex - 1)?.id
@ -221,9 +223,10 @@ class NetworkTimelineViewModel @Inject constructor(
val activeAccount = accountManager.activeAccount!!
val data: MutableList<StatusViewData> = statuses.map { status ->
status.toViewData(
isShowingContent = activeAccount.alwaysShowSensitiveMedia || !status.actionableStatus.sensitive,
isShowingContent = status.shouldShowContent(activeAccount.alwaysShowSensitiveMedia, kind.toFilterKind()),
isExpanded = activeAccount.alwaysOpenSpoiler,
isCollapsed = true
isCollapsed = true,
filter = status.getApplicableFilter(kind.toFilterKind()),
)
}.toMutableList()
@ -255,7 +258,7 @@ class NetworkTimelineViewModel @Inject constructor(
statusData.removeAll { status ->
when (status) {
is StatusViewData.Placeholder -> lastId.isLessThan(status.id) && status.id.isLessThanOrEqual(
is StatusViewData.LoadMore -> lastId.isLessThan(status.id) && status.id.isLessThanOrEqual(
firstId
)
@ -266,7 +269,7 @@ class NetworkTimelineViewModel @Inject constructor(
}
} else {
data[data.size - 1] =
StatusViewData.Placeholder(statuses.last().id, isLoading = false)
StatusViewData.LoadMore(statuses.last().id, isLoading = false)
}
}
@ -285,8 +288,8 @@ class NetworkTimelineViewModel @Inject constructor(
Log.w("NetworkTimelineVM", "failed loading statuses", e)
val index =
statusData.indexOfFirst { it is StatusViewData.Placeholder && it.id == placeholderId }
statusData[index] = StatusViewData.Placeholder(placeholderId, isLoading = false)
statusData.indexOfFirst { it is StatusViewData.LoadMore && it.id == placeholderId }
statusData[index] = StatusViewData.LoadMore(placeholderId, isLoading = false)
currentSource?.invalidate()
}

15
app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/TimelineViewModel.kt

@ -178,20 +178,18 @@ abstract class TimelineViewModel(
/** Triggered when currently displayed data must be reloaded. */
protected abstract suspend fun invalidate()
protected fun shouldFilterStatus(statusViewData: StatusViewData): Filter.Action {
val status = statusViewData.asStatusOrNull()?.status ?: return Filter.Action.NONE
protected fun shouldFilterStatus(status: Status): Filter? {
return if (
(status.isReply && filterRemoveReplies) ||
(status.reblog != null && filterRemoveReblogs) ||
(status.account.id == status.reblog?.account?.id && filterRemoveSelfReblogs)
) {
Filter.Action.HIDE
Filter(context = listOf(kind.toFilterKind()), action = Filter.Action.HIDE)
} else if (status.actionableStatus.account.id == activeAccountFlow.value?.accountId) {
// Mastodon filters don't apply for own posts
Filter.Action.NONE
null
} else {
statusViewData.filterAction = filterModel.shouldFilterStatus(status.actionableStatus)
statusViewData.filterAction
filterModel.shouldFilterStatus(status.actionableStatus)
}
}
@ -244,9 +242,8 @@ abstract class TimelineViewModel(
private const val TAG = "TimelineVM"
internal const val LOAD_AT_ONCE = 30
fun filterContextMatchesKind(kind: Kind, filterContext: List<String>): Boolean {
return filterContext.contains(kind.toFilterKind().kind)
}
fun filterContextMatchesKind(kind: Kind, filterContext: List<Filter.Kind>): Boolean =
filterContext.contains(kind.toFilterKind())
}
enum class Kind {

2
app/src/main/java/com/keylesspalace/tusky/components/trending/viewmodel/TrendingTagsViewModel.kt

@ -95,7 +95,7 @@ class TrendingTagsViewModel @Inject constructor(
TrendingTagsUiState(emptyList(), LoadingState.LOADED)
} else {
val homeFilters = deferredFilters.await().getOrNull()?.filter { filter ->
filter.context.contains(Filter.Kind.HOME.kind)
filter.context.contains(Filter.Kind.HOME)
}
val tags = tagResponse
.filter { tag ->

2
app/src/main/java/com/keylesspalace/tusky/components/viewthread/ThreadAdapter.kt

@ -71,7 +71,7 @@ class ThreadAdapter(
val viewData = getItem(position)
return if (viewData.isDetailed) {
VIEW_TYPE_STATUS_DETAILED
} else if (viewData.filterAction == Filter.Action.WARN) {
} else if (viewData.filter?.action == Filter.Action.WARN) {
VIEW_TYPE_STATUS_FILTERED
} else {
VIEW_TYPE_STATUS

41
app/src/main/java/com/keylesspalace/tusky/components/viewthread/ViewThreadFragment.kt

@ -34,6 +34,7 @@ import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.SimpleItemAnimator
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout.OnRefreshListener
import at.connyduck.calladapter.networkresult.onFailure
import at.connyduck.sparkbutton.SparkButton
import com.google.android.material.snackbar.Snackbar
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.components.accountlist.AccountListActivity
@ -55,6 +56,8 @@ import com.keylesspalace.tusky.util.show
import com.keylesspalace.tusky.util.startActivityWithSlideInAnimation
import com.keylesspalace.tusky.util.updateRelativeTimePeriodically
import com.keylesspalace.tusky.util.viewBinding
import com.keylesspalace.tusky.view.ConfirmationBottomSheet.Companion.confirmFavourite
import com.keylesspalace.tusky.view.ConfirmationBottomSheet.Companion.confirmReblog
import com.keylesspalace.tusky.viewdata.AttachmentViewData.Companion.list
import com.keylesspalace.tusky.viewdata.StatusViewData
import com.keylesspalace.tusky.viewdata.TranslationViewData
@ -88,6 +91,8 @@ class ViewThreadFragment :
private var alwaysShowSensitiveMedia = false
private var alwaysOpenSpoiler = false
private var buttonToAnimate: SparkButton? = null
/**
* State of the "reveal" menu item that shows/hides content that is behind a content
* warning. Setting this invalidates the menu to redraw the menu item.
@ -115,8 +120,6 @@ class ViewThreadFragment :
} else {
CardViewMode.NONE
},
confirmReblogs = preferences.getBoolean(PrefKeys.CONFIRM_REBLOGS, true),
confirmFavourites = preferences.getBoolean(PrefKeys.CONFIRM_FAVOURITES, false),
hideStats = preferences.getBoolean(PrefKeys.WELLBEING_HIDE_STATS_POSTS, false),
animateEmojis = preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false),
showStatsInline = preferences.getBoolean(PrefKeys.SHOW_STATS_INLINE, false),
@ -266,6 +269,7 @@ class ViewThreadFragment :
override fun onDestroyView() {
// Clear the adapter to prevent leaking the View
adapter = null
buttonToAnimate = null
super.onDestroyView()
}
@ -334,9 +338,23 @@ class ViewThreadFragment :
super.reply(viewData.status)
}
override fun onReblog(reblog: Boolean, position: Int, visibility: Status.Visibility) {
override fun onReblog(reblog: Boolean, position: Int, visibility: Status.Visibility?, button: SparkButton?) {
val status = adapter?.currentList?.getOrNull(position) ?: return
viewModel.reblog(reblog, status, visibility)
buttonToAnimate = button
if (reblog && visibility == null) {
confirmReblog(preferences) { visibility ->
viewModel.reblog(true, status, visibility)
buttonToAnimate?.playAnimation()
buttonToAnimate?.isChecked = true
}
} else {
viewModel.reblog(reblog, status, visibility ?: Status.Visibility.PUBLIC)
if (reblog) {
buttonToAnimate?.playAnimation()
}
buttonToAnimate?.isChecked = false
}
}
override val onMoreTranslate: ((translate: Boolean, position: Int) -> Unit) =
@ -369,9 +387,20 @@ class ViewThreadFragment :
viewModel.untranslate(status)
}
override fun onFavourite(favourite: Boolean, position: Int) {
override fun onFavourite(favourite: Boolean, position: Int, button: SparkButton?) {
val status = adapter?.currentList?.getOrNull(position) ?: return
viewModel.favorite(favourite, status)
buttonToAnimate = button
if (favourite) {
confirmFavourite(preferences) {
viewModel.favorite(true, status)
buttonToAnimate?.playAnimation()
buttonToAnimate?.isChecked = true
}
} else {
viewModel.favorite(false, status)
buttonToAnimate?.isChecked = false
}
}
override fun onBookmark(bookmark: Boolean, position: Int) {

15
app/src/main/java/com/keylesspalace/tusky/components/viewthread/ViewThreadViewModel.kt

@ -61,9 +61,9 @@ class ViewThreadViewModel @Inject constructor(
private val api: MastodonApi,
private val filterModel: FilterModel,
private val timelineCases: TimelineCases,
eventHub: EventHub,
private val accountManager: AccountManager,
private val db: AppDatabase,
eventHub: EventHub,
accountManager: AccountManager,
) : ViewModel() {
private val activeAccount = accountManager.activeAccount!!
@ -312,6 +312,7 @@ class ViewThreadViewModel @Inject constructor(
isCollapsed = viewData.isCollapsed,
isDetailed = viewData.isDetailed,
translation = viewData.translation,
filter = viewData.filter,
)
}
}
@ -421,8 +422,8 @@ class ViewThreadViewModel @Inject constructor(
if (status.isDetailed || status.status.account.id == activeAccount.accountId) {
true
} else {
status.filterAction = filterModel.shouldFilterStatus(status.status)
status.filterAction != Filter.Action.HIDE
status.filter = filterModel.shouldFilterStatus(status.status)
status.filter?.action != Filter.Action.HIDE
}
}
}
@ -432,11 +433,11 @@ class ViewThreadViewModel @Inject constructor(
it.id == this.id
}
return toViewData(
isShowingContent = oldStatus?.isShowingContent
?: (alwaysShowSensitiveMedia || !actionableStatus.sensitive),
isShowingContent = oldStatus?.isShowingContent ?: actionableStatus.shouldShowContent(alwaysShowSensitiveMedia, Filter.Kind.THREAD),
isExpanded = oldStatus?.isExpanded ?: alwaysOpenSpoiler,
isCollapsed = oldStatus?.isCollapsed ?: !isDetailed,
isDetailed = oldStatus?.isDetailed ?: isDetailed
isDetailed = oldStatus?.isDetailed ?: isDetailed,
filter = oldStatus?.filter ?: actionableStatus.getApplicableFilter(Filter.Kind.THREAD),
)
}

4
app/src/main/java/com/keylesspalace/tusky/components/viewthread/edits/ViewEditsAdapter.kt

@ -2,7 +2,6 @@ package com.keylesspalace.tusky.components.viewthread.edits
import android.content.Context
import android.graphics.Typeface.DEFAULT_BOLD
import android.graphics.drawable.ColorDrawable
import android.graphics.drawable.Drawable
import android.text.Editable
import android.text.SpannableStringBuilder
@ -12,6 +11,7 @@ import android.util.TypedValue
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.core.graphics.drawable.toDrawable
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.bumptech.glide.Glide
@ -188,7 +188,7 @@ class ViewEditsAdapter(
val placeholder: Drawable = if (blurhash != null && useBlurhash) {
BlurhashDrawable(context, blurhash)
} else {
ColorDrawable(MaterialColors.getColor(imageView, R.attr.colorBackgroundAccent))
MaterialColors.getColor(imageView, R.attr.colorBackgroundAccent).toDrawable()
}
if (attachment.previewUrl.isNullOrEmpty()) {

3
app/src/main/java/com/keylesspalace/tusky/db/AppDatabase.java

@ -65,7 +65,7 @@ import java.io.File;
},
// Note: Starting with version 54, database versions in Tusky are always even.
// This is to reserve odd version numbers for use by forks.
version = 68,
version = 70,
autoMigrations = {
@AutoMigration(from = 48, to = 49),
@AutoMigration(from = 49, to = 50, spec = AppDatabase.MIGRATION_49_50.class),
@ -76,6 +76,7 @@ import java.io.File;
@AutoMigration(from = 62, to = 64), // filterV2Available in InstanceEntity
@AutoMigration(from = 64, to = 66), // added profileHeaderUrl to AccountEntity
@AutoMigration(from = 66, to = 68, spec = AppDatabase.MIGRATION_66_68.class), // added event and moderationAction to NotificationEntity, new NotificationPolicyEntity
@AutoMigration(from = 68, to = 70), // added mastodonApiVersion to InstanceEntity
}
)
public abstract class AppDatabase extends RoomDatabase {

3
app/src/main/java/com/keylesspalace/tusky/db/entity/InstanceEntity.kt

@ -42,6 +42,8 @@ data class InstanceEntity(
val maxFieldNameLength: Int?,
val maxFieldValueLength: Int?,
val translationEnabled: Boolean?,
val mastodonApiVersion: Int?,
// ToDo: Remove this again when filter v1 support is dropped
@ColumnInfo(defaultValue = "false") val filterV2Supported: Boolean = false,
val vapidKey: String?
@ -70,5 +72,6 @@ data class InstanceInfoEntity(
val maxFieldNameLength: Int?,
val maxFieldValueLength: Int?,
val translationEnabled: Boolean?,
val mastodonApiVersion: Int?,
val vapidKey: String?
)

18
app/src/main/java/com/keylesspalace/tusky/di/PlayerModule.kt

@ -79,16 +79,14 @@ object PlayerModule {
textRendererOutput,
metadataRendererOutput ->
arrayOf(
MediaCodecVideoRenderer(
context,
MediaCodecSelector.DEFAULT,
DefaultRenderersFactory.DEFAULT_ALLOWED_VIDEO_JOINING_TIME_MS,
// enableDecoderFallback = true, helps playing videos even if one decoder fails
true,
eventHandler,
videoRendererEventListener,
DefaultRenderersFactory.MAX_DROPPED_VIDEO_FRAME_COUNT_TO_NOTIFY
),
MediaCodecVideoRenderer.Builder(context)
.setMediaCodecSelector(MediaCodecSelector.DEFAULT)
.setAllowedJoiningTimeMs(DefaultRenderersFactory.DEFAULT_ALLOWED_VIDEO_JOINING_TIME_MS)
.setEnableDecoderFallback(true)
.setEventHandler(eventHandler)
.setEventListener(videoRendererEventListener)
.setMaxDroppedFramesToNotify(DefaultRenderersFactory.MAX_DROPPED_VIDEO_FRAME_COUNT_TO_NOTIFY)
.build(),
MediaCodecAudioRenderer(
context,
MediaCodecSelector.DEFAULT,

41
app/src/main/java/com/keylesspalace/tusky/entity/Filter.kt

@ -9,39 +9,60 @@ import kotlinx.parcelize.Parcelize
@JsonClass(generateAdapter = true)
@Parcelize
data class Filter(
val id: String,
val title: String,
val context: List<String>,
val id: String = "",
val title: String = "",
val context: List<Kind>,
@Json(name = "expires_at") val expiresAt: Date? = null,
@Json(name = "filter_action") val filterAction: String,
@Json(name = "filter_action") val action: Action,
// This field is mandatory according to the API documentation but is in fact optional in some instances
val keywords: List<FilterKeyword> = emptyList(),
// val statuses: List<FilterStatus>,
) : Parcelable {
@JsonClass(generateAdapter = false)
enum class Action(val action: String) {
@Json(name = "none")
NONE("none"),
@Json(name = "blur")
BLUR("blur"),
@Json(name = "warn")
WARN("warn"),
@Json(name = "hide")
HIDE("hide");
// Retrofit will call toString when sending this class as part of a form-urlencoded body.
override fun toString() = action
companion object {
fun from(action: String): Action = entries.firstOrNull { it.action == action } ?: WARN
}
}
@JsonClass(generateAdapter = false)
enum class Kind(val kind: String) {
@Json(name = "home")
HOME("home"),
@Json(name = "notifications")
NOTIFICATIONS("notifications"),
@Json(name = "public")
PUBLIC("public"),
@Json(name = "thread")
THREAD("thread"),
@Json(name = "account")
ACCOUNT("account");
// Retrofit will call toString when sending this class as part of a form-urlencoded body.
override fun toString() = kind
companion object {
fun from(kind: String): Kind = entries.firstOrNull { it.kind == kind } ?: PUBLIC
}
}
val action: Action
get() = Action.from(filterAction)
val kinds: List<Kind>
get() = context.map { Kind.from(it) }
}

26
app/src/main/java/com/keylesspalace/tusky/entity/FilterV1.kt

@ -40,20 +40,18 @@ data class FilterV1(
return other.id == id
}
fun toFilter(): Filter {
return Filter(
id = id,
title = phrase,
context = context,
expiresAt = expiresAt,
filterAction = Filter.Action.WARN.action,
keywords = listOf(
FilterKeyword(
id = id,
keyword = phrase,
wholeWord = wholeWord
)
fun toFilter() = Filter(
id = id,
title = phrase,
context = context.map(Filter.Kind::from),
expiresAt = expiresAt,
action = Filter.Action.WARN,
keywords = listOf(
FilterKeyword(
id = id,
keyword = phrase,
wholeWord = wholeWord
)
)
}
)
}

8
app/src/main/java/com/keylesspalace/tusky/entity/Instance.kt

@ -17,7 +17,8 @@ data class Instance(
// val registrations: Registrations,
// val contact: Contact,
val rules: List<Rule> = emptyList(),
val pleroma: PleromaConfiguration? = null
val pleroma: PleromaConfiguration? = null,
@Json(name = "api_versions") val apiVersions: ApiVersions? = null,
) {
@JsonClass(generateAdapter = true)
data class Usage(val users: Users) {
@ -46,7 +47,7 @@ data class Instance(
val statuses: Statuses? = null,
@Json(name = "media_attachments") val mediaAttachments: MediaAttachments? = null,
val polls: Polls? = null,
val translation: Translation? = null
val translation: Translation? = null,
) {
@JsonClass(generateAdapter = true)
data class Urls(@Json(name = "streaming_api") val streamingApi: String? = null)
@ -105,4 +106,7 @@ data class Instance(
@JsonClass(generateAdapter = true)
data class Rule(val id: String, val text: String)
@JsonClass(generateAdapter = true)
data class ApiVersions(val mastodon: Int? = null)
}

6
app/src/main/java/com/keylesspalace/tusky/entity/Status.kt

@ -158,6 +158,12 @@ data class Status(
return builder.toString()
}
fun getApplicableFilter(kind: Filter.Kind): Filter? =
actionableStatus.filtered?.filter { it.filter.context.contains(kind) }?.maxByOrNull { it.filter.action.ordinal }?.filter
fun shouldShowContent(alwayShowSensitiveContent: Boolean, context: Filter.Kind): Boolean =
alwayShowSensitiveContent || (!actionableStatus.sensitive && getApplicableFilter(context)?.action != Filter.Action.BLUR)
@JsonClass(generateAdapter = true)
data class Mention(
val id: String,

20
app/src/main/java/com/keylesspalace/tusky/fragment/SFragment.kt

@ -18,7 +18,6 @@ import android.Manifest
import android.app.DownloadManager
import android.content.DialogInterface
import android.content.Intent
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.os.Environment
@ -31,10 +30,12 @@ import androidx.annotation.LayoutRes
import androidx.appcompat.widget.PopupMenu
import androidx.core.app.ActivityOptionsCompat
import androidx.core.content.getSystemService
import androidx.core.net.toUri
import androidx.fragment.app.Fragment
import androidx.lifecycle.lifecycleScope
import at.connyduck.calladapter.networkresult.fold
import at.connyduck.calladapter.networkresult.onFailure
import at.connyduck.sparkbutton.SparkButton
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.snackbar.Snackbar
import com.keylesspalace.tusky.BaseActivity
@ -74,7 +75,7 @@ import kotlinx.coroutines.launch
* up what needs to be where. */
abstract class SFragment(@LayoutRes contentLayoutId: Int) : Fragment(contentLayoutId) {
protected abstract fun removeItem(position: Int)
protected abstract fun onReblog(reblog: Boolean, position: Int, visibility: Status.Visibility)
protected abstract fun onReblog(reblog: Boolean, position: Int, visibility: Status.Visibility?, button: SparkButton?)
/** `null` if translation is not supported on this screen */
protected abstract val onMoreTranslate: ((translate: Boolean, position: Int) -> Unit)?
@ -318,12 +319,12 @@ abstract class SFragment(@LayoutRes contentLayoutId: Int) : Fragment(contentLayo
}
R.id.status_unreblog_private -> {
onReblog(false, position, Status.Visibility.PUBLIC)
onReblog(false, position, Status.Visibility.PRIVATE, null)
return@setOnMenuItemClickListener true
}
R.id.status_reblog_private -> {
onReblog(true, position, Status.Visibility.PUBLIC)
onReblog(true, position, Status.Visibility.PRIVATE, null)
return@setOnMenuItemClickListener true
}
@ -398,7 +399,7 @@ abstract class SFragment(@LayoutRes contentLayoutId: Int) : Fragment(contentLayo
val (attachment) = attachments[urlIndex]
when (attachment.type) {
Attachment.Type.GIFV, Attachment.Type.VIDEO, Attachment.Type.IMAGE, Attachment.Type.AUDIO -> {
val intent = newIntent(context, attachments, urlIndex)
val intent = newIntent(requireContext(), attachments, urlIndex)
if (view != null) {
val url = attachment.url
view.transitionName = url
@ -432,7 +433,7 @@ abstract class SFragment(@LayoutRes contentLayoutId: Int) : Fragment(contentLayo
.setMessage(R.string.dialog_delete_post_warning)
.setPositiveButton(android.R.string.ok) { _: DialogInterface?, _: Int ->
viewLifecycleOwner.lifecycleScope.launch {
val result = timelineCases.delete(id).exceptionOrNull()
val result = timelineCases.delete(id, true).exceptionOrNull()
if (result != null) {
Log.w("SFragment", "error deleting status", result)
Toast.makeText(requireContext(), R.string.error_generic, Toast.LENGTH_SHORT).show()
@ -456,7 +457,7 @@ abstract class SFragment(@LayoutRes contentLayoutId: Int) : Fragment(contentLayo
.setMessage(R.string.dialog_redraft_post_warning)
.setPositiveButton(android.R.string.ok) { _: DialogInterface?, _: Int ->
viewLifecycleOwner.lifecycleScope.launch {
timelineCases.delete(id).fold(
timelineCases.delete(id, false).fold(
{ deletedStatus ->
removeItem(position)
val sourceStatus = if (deletedStatus.isEmpty) {
@ -542,7 +543,7 @@ abstract class SFragment(@LayoutRes contentLayoutId: Int) : Fragment(contentLayo
val downloadManager: DownloadManager = requireContext().getSystemService()!!
for (url in mediaUrls) {
val uri = Uri.parse(url)
val uri = url.toUri()
downloadManager.enqueue(
DownloadManager.Request(uri).apply {
setDestinationInExternalPublicDir(
@ -568,7 +569,6 @@ abstract class SFragment(@LayoutRes contentLayoutId: Int) : Fragment(contentLayo
}
companion object {
private const val TAG = "SFragment"
private const val PENDING_MEDIA_DOWNLOADS_STATE_KEY = "pending_media_downloads"
private fun accountIsInMentions(
@ -576,7 +576,7 @@ abstract class SFragment(@LayoutRes contentLayoutId: Int) : Fragment(contentLayo
mentions: List<Status.Mention>
): Boolean {
return mentions.any { mention ->
account?.username == mention.username && account.domain == Uri.parse(mention.url)?.host
account?.username == mention.username && account.domain == mention.url.toUri().host
}
}
}

14
app/src/main/java/com/keylesspalace/tusky/fragment/ViewImageFragment.kt

@ -28,8 +28,8 @@ import android.view.View
import android.view.ViewGroup
import android.widget.ImageView
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat.Type.displayCutout
import androidx.core.view.WindowInsetsCompat.Type.systemBars
import androidx.core.view.updateLayoutParams
import androidx.core.view.updatePadding
import androidx.lifecycle.lifecycleScope
import com.bumptech.glide.Glide
@ -115,14 +115,18 @@ class ViewImageFragment : ViewMediaFragment() {
val descriptionBottomSheet = BottomSheetBehavior.from(binding.captionSheet)
ViewCompat.setOnApplyWindowInsetsListener(binding.root) { _, insets ->
val topInsets = insets.getInsets(displayCutout()).top
val bottomInsets = insets.getInsets(systemBars()).bottom
val mediaDescriptionBottomPadding = requireContext().resources.getDimensionPixelSize(R.dimen.media_description_sheet_bottom_padding)
val mediaDescriptionPeekHeight = requireContext().resources.getDimensionPixelSize(R.dimen.media_description_sheet_peek_height)
val imageViewBottomMargin = requireContext().resources.getDimensionPixelSize(R.dimen.media_image_view_bottom_margin)
binding.mediaDescription.updatePadding(bottom = mediaDescriptionBottomPadding + bottomInsets)
descriptionBottomSheet.setPeekHeight(mediaDescriptionPeekHeight + bottomInsets, false)
binding.photoView.updateLayoutParams<ViewGroup.MarginLayoutParams> { bottomMargin = imageViewBottomMargin + bottomInsets }
insets.inset(0, 0, 0, bottomInsets)
binding.photoView.updatePadding(
top = topInsets,
bottom = bottomInsets
)
binding.photoView.invalidate()
insets.inset(0, topInsets, 0, bottomInsets)
}
val singleTapDetector = GestureDetector(
@ -282,9 +286,11 @@ class ViewImageFragment : ViewMediaFragment() {
// Request image from the network on fail load image from cache
.error(
glide.load(url)
.override(Target.SIZE_ORIGINAL, Target.SIZE_ORIGINAL)
.centerInside()
.addListener(ImageRequestListener(false, isThumbnailRequest = false))
)
.override(Target.SIZE_ORIGINAL, Target.SIZE_ORIGINAL)
.centerInside()
.addListener(ImageRequestListener(true, isThumbnailRequest = false))
.into(photoView)

16
app/src/main/java/com/keylesspalace/tusky/interfaces/StatusActionListener.kt

@ -15,12 +15,24 @@
package com.keylesspalace.tusky.interfaces
import android.view.View
import at.connyduck.sparkbutton.SparkButton
import com.keylesspalace.tusky.entity.Status
interface StatusActionListener : LinkListener {
fun onReply(position: Int)
fun onReblog(reblog: Boolean, position: Int, visibility: Status.Visibility = Status.Visibility.PUBLIC)
fun onFavourite(favourite: Boolean, position: Int)
/**
* Reblog the post at [position]
* @param visibility The visibility to use for the reblog, if the user has already chosen it, null otherwise
* @param button Optional button to animate
*/
fun onReblog(reblog: Boolean, position: Int, visibility: Status.Visibility?, button: SparkButton? = null)
/**
* Favourite the post at [position]
* @param button Optional button to animate
*/
fun onFavourite(favourite: Boolean, position: Int, button: SparkButton? = null)
fun onBookmark(bookmark: Boolean, position: Int)
fun onMore(view: View, position: Int)
fun onViewMedia(position: Int, attachmentIndex: Int, view: View?)

20
app/src/main/java/com/keylesspalace/tusky/network/FilterModel.kt

@ -66,13 +66,13 @@ class FilterModel @Inject constructor(
)
}
fun shouldFilterStatus(status: Status): Filter.Action {
fun shouldFilterStatus(status: Status): Filter? {
if (v1) {
// Patterns are expensive and thread-safe, matchers are neither.
val matcher = pattern?.matcher("") ?: return Filter.Action.NONE
val matcher = pattern?.matcher("") ?: return null
if (status.poll?.options?.any { matcher.reset(it.title).find() } == true) {
return Filter.Action.HIDE
return Filter(context = listOf(kind), action = Filter.Action.HIDE)
}
val spoilerText = status.actionableStatus.spoilerText
@ -83,21 +83,13 @@ class FilterModel @Inject constructor(
(spoilerText.isNotEmpty() && matcher.reset(spoilerText).find()) ||
(attachmentsDescriptions.isNotEmpty() && matcher.reset(attachmentsDescriptions.joinToString("\n")).find())
) {
Filter.Action.HIDE
return Filter(context = listOf(kind), action = Filter.Action.HIDE)
} else {
Filter.Action.NONE
null
}
}
val matchingKind = status.filtered.orEmpty().filter { result ->
result.filter.kinds.contains(kind)
}
return if (matchingKind.isEmpty()) {
Filter.Action.NONE
} else {
matchingKind.maxOf { it.filter.action }
}
return status.getApplicableFilter(kind)
}
private fun filterToRegexToken(filter: FilterV1): String? {

17
app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.kt

@ -250,7 +250,10 @@ interface MastodonApi {
): Response<List<TimelineAccount>>
@DELETE("api/v1/statuses/{id}")
suspend fun deleteStatus(@Path("id") statusId: String): NetworkResult<DeletedStatus>
suspend fun deleteStatus(
@Path("id") statusId: String,
@Query("delete_media") deleteMedia: Boolean? = null
): NetworkResult<DeletedStatus>
@FormUrlEncoded
@POST("api/v1/statuses/{id}/reblog")
@ -402,10 +405,10 @@ interface MastodonApi {
suspend fun unsubscribeAccount(@Path("id") accountId: String): NetworkResult<Relationship>
@GET("api/v1/blocks")
suspend fun blocks(@Query("max_id") maxId: String?): Response<List<TimelineAccount>>
suspend fun blocks(@Query("max_id") maxId: String? = null): Response<List<TimelineAccount>>
@GET("api/v1/mutes")
suspend fun mutes(@Query("max_id") maxId: String?): Response<List<TimelineAccount>>
suspend fun mutes(@Query("max_id") maxId: String? = null): Response<List<TimelineAccount>>
@GET("api/v1/domain_blocks")
suspend fun domainBlocks(
@ -561,8 +564,8 @@ interface MastodonApi {
@POST("api/v2/filters")
suspend fun createFilter(
@Field("title") title: String,
@Field("context[]") context: List<String>,
@Field("filter_action") filterAction: String,
@Field("context[]") context: List<Filter.Kind>,
@Field("filter_action") filterAction: Filter.Action,
@Field("expires_in") expiresIn: FilterExpiration?
): NetworkResult<Filter>
@ -571,8 +574,8 @@ interface MastodonApi {
suspend fun updateFilter(
@Path("id") id: String,
@Field("title") title: String? = null,
@Field("context[]") context: List<String>? = null,
@Field("filter_action") filterAction: String? = null,
@Field("context[]") context: List<Filter.Kind>? = null,
@Field("filter_action") filterAction: Filter.Action? = null,
@Field("expires_in") expires: FilterExpiration? = null
): NetworkResult<Filter>

2
app/src/main/java/com/keylesspalace/tusky/service/SendStatusService.kt

@ -127,7 +127,7 @@ class SendStatusService : Service() {
cancelSendingIntent(sendingNotificationId)
)
if (statusesToSend.size == 0 || Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
if (statusesToSend.isEmpty() || Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_DETACH)
startForeground(sendingNotificationId, builder.build())
} else {

3
app/src/main/java/com/keylesspalace/tusky/settings/SettingsConstants.kt

@ -106,6 +106,9 @@ object PrefKeys {
/** UI text scaling factor, stored as float, 100 = 100% = no scaling */
const val UI_TEXT_SCALE_RATIO = "uiTextScaleRatio"
/** UI text scaling factor, stored as float, 100 = 100% = no scaling */
const val REBLOG_PRIVACY = "reblogPrivacy"
object Deprecated {
const val FAB_HIDE = "fabHide"
}

4
app/src/main/java/com/keylesspalace/tusky/usecase/TimelineCases.kt

@ -109,8 +109,8 @@ class TimelineCases @Inject constructor(
}
}
suspend fun delete(statusId: String): NetworkResult<DeletedStatus> {
return mastodonApi.deleteStatus(statusId)
suspend fun delete(statusId: String, deleteMedia: Boolean): NetworkResult<DeletedStatus> {
return mastodonApi.deleteStatus(statusId, deleteMedia)
.onSuccess { eventHub.dispatch(StatusDeletedEvent(statusId)) }
.onFailure { Log.w(TAG, "Failed to delete status", it) }
}

46
app/src/main/java/com/keylesspalace/tusky/util/CustomEmojiHelper.kt

@ -24,6 +24,7 @@ import android.graphics.drawable.Drawable
import android.text.style.ReplacementSpan
import android.view.View
import android.widget.TextView
import androidx.core.graphics.withSave
import androidx.core.text.toSpannable
import com.bumptech.glide.Glide
import com.bumptech.glide.request.target.CustomTarget
@ -155,32 +156,31 @@ class EmojiSpan(view: View) : ReplacementSpan() {
paint: Paint
) {
imageDrawable?.let { drawable ->
canvas.save()
// start with a width relative to the text size
var emojiWidth = paint.textSize * 1.1
// calculate the height, keeping the aspect ratio correct
val drawableWidth = drawable.intrinsicWidth
val drawableHeight = drawable.intrinsicHeight
var emojiHeight = emojiWidth / drawableWidth * drawableHeight
canvas.withSave {
// start with a width relative to the text size
var emojiWidth = paint.textSize * 1.1
// calculate the height, keeping the aspect ratio correct
val drawableWidth = drawable.intrinsicWidth
val drawableHeight = drawable.intrinsicHeight
var emojiHeight = emojiWidth / drawableWidth * drawableHeight
// how much vertical space there is draw the emoji
val drawableSpace = (bottom - top).toDouble()
// in case the calculated height is bigger than the available space, scale the emoji down, preserving aspect ratio
if (emojiHeight > drawableSpace) {
emojiWidth *= drawableSpace / emojiHeight
emojiHeight = drawableSpace
}
drawable.setBounds(0, 0, emojiWidth.toInt(), emojiHeight.toInt())
// how much vertical space there is draw the emoji
val drawableSpace = (bottom - top).toDouble()
// vertically center the emoji in the line
val transY = top + (drawableSpace / 2 - emojiHeight / 2)
// in case the calculated height is bigger than the available space, scale the emoji down, preserving aspect ratio
if (emojiHeight > drawableSpace) {
emojiWidth *= drawableSpace / emojiHeight
emojiHeight = drawableSpace
translate(x, transY.toFloat())
drawable.draw(this)
}
drawable.setBounds(0, 0, emojiWidth.toInt(), emojiHeight.toInt())
// vertically center the emoji in the line
val transY = top + (drawableSpace / 2 - emojiHeight / 2)
canvas.translate(x, transY.toFloat())
drawable.draw(canvas)
canvas.restore()
}
}

8
app/src/main/java/com/keylesspalace/tusky/util/LinkHelper.kt

@ -413,7 +413,7 @@ private fun openLinkInBrowser(uri: Uri?, context: Context) {
try {
context.startActivity(intent)
} catch (e: ActivityNotFoundException) {
Log.w(TAG, "Activity was not found for intent, $intent")
Log.w(TAG, "Activity was not found for intent, $intent", e)
}
}
@ -430,6 +430,8 @@ fun openLinkInCustomTab(uri: Uri, context: Context) {
materialR.attr.colorSurface,
Color.BLACK
)
@Suppress("DEPRECATION")
val navigationbarColor = MaterialColors.getColor(
context,
android.R.attr.navigationBarColor,
@ -454,7 +456,7 @@ fun openLinkInCustomTab(uri: Uri, context: Context) {
try {
customTabsIntent.launchUrl(context, uri)
} catch (e: ActivityNotFoundException) {
Log.w(TAG, "Activity was not found for intent $customTabsIntent")
Log.w(TAG, "Activity was not found for intent $customTabsIntent", e)
openLinkInBrowser(uri, context)
}
}
@ -480,7 +482,7 @@ fun looksLikeMastodonUrl(urlString: String): Boolean {
val uri: URI
try {
uri = URI(urlString)
} catch (e: URISyntaxException) {
} catch (_: URISyntaxException) {
return false
}

4
app/src/main/java/com/keylesspalace/tusky/util/ListStatusAccessibilityDelegate.kt

@ -105,8 +105,8 @@ class ListStatusAccessibilityDelegate(
R.id.action_unfavourite -> statusActionListener.onFavourite(false, pos)
R.id.action_bookmark -> statusActionListener.onBookmark(true, pos)
R.id.action_unbookmark -> statusActionListener.onBookmark(false, pos)
R.id.action_reblog -> statusActionListener.onReblog(true, pos)
R.id.action_unreblog -> statusActionListener.onReblog(false, pos)
R.id.action_reblog -> statusActionListener.onReblog(true, pos, null)
R.id.action_unreblog -> statusActionListener.onReblog(false, pos, null)
R.id.action_open_profile -> {
interrupt()
statusActionListener.onViewAccount(

7
app/src/main/java/com/keylesspalace/tusky/util/ListUtils.kt

@ -17,13 +17,6 @@
package com.keylesspalace.tusky.util
/**
* Copies elements to destination, removing duplicates and preserving original order.
*/
fun <T, C : MutableCollection<in T>> Iterable<T>.removeDuplicatesTo(destination: C): C {
return filterTo(destination, HashSet<T>()::add)
}
inline fun <T> List<T>.withoutFirstWhich(predicate: (T) -> Boolean): List<T> {
val index = indexOfFirst(predicate)
if (index == -1) {

13
app/src/main/java/com/keylesspalace/tusky/util/LocaleManager.kt

@ -19,6 +19,7 @@ import android.content.Context
import android.content.SharedPreferences
import android.os.Build
import androidx.appcompat.app.AppCompatDelegate
import androidx.core.content.edit
import androidx.core.os.LocaleListCompat
import androidx.preference.PreferenceDataStore
import com.keylesspalace.tusky.R
@ -44,9 +45,9 @@ class LocaleManager @Inject constructor(
// hand over the old setting to the system and save a dummy value in Shared Preferences
applyLanguageToApp(language)
preferences.edit()
.putString(PrefKeys.LANGUAGE, HANDLED_BY_SYSTEM)
.apply()
preferences.edit {
putString(PrefKeys.LANGUAGE, HANDLED_BY_SYSTEM)
}
}
} else {
// on Android < 13 we have to apply the language at every app start
@ -58,9 +59,9 @@ class LocaleManager @Inject constructor(
// if we are on Android < 13 we have to save the selected language so we can apply it at appstart
// on Android 13+ the system handles it for us
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
preferences.edit()
.putString(PrefKeys.LANGUAGE, value)
.apply()
preferences.edit {
putString(PrefKeys.LANGUAGE, value)
}
}
applyLanguageToApp(value)
}

4
app/src/main/java/com/keylesspalace/tusky/util/RickRoll.kt

@ -2,7 +2,7 @@ package com.keylesspalace.tusky.util
import android.content.Context
import android.content.Intent
import android.net.Uri
import androidx.core.net.toUri
import com.keylesspalace.tusky.R
fun shouldRickRoll(context: Context, domain: String) =
@ -11,7 +11,7 @@ fun shouldRickRoll(context: Context, domain: String) =
}
fun rickRoll(context: Context) {
val uri = Uri.parse(context.getString(R.string.rick_roll_url))
val uri = context.getString(R.string.rick_roll_url).toUri()
val intent = Intent(Intent.ACTION_VIEW, uri).apply {
addCategory(Intent.CATEGORY_BROWSABLE)
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)

4
app/src/main/java/com/keylesspalace/tusky/util/ShareShortcutHelper.kt

@ -19,13 +19,13 @@ package com.keylesspalace.tusky.util
import android.content.Context
import android.content.Intent
import android.graphics.Bitmap
import android.graphics.Canvas
import android.util.Log
import androidx.appcompat.content.res.AppCompatResources
import androidx.core.app.Person
import androidx.core.content.pm.ShortcutInfoCompat
import androidx.core.content.pm.ShortcutManagerCompat
import androidx.core.graphics.createBitmap
import androidx.core.graphics.drawable.IconCompat
import com.bumptech.glide.Glide
import com.bumptech.glide.load.engine.GlideException
@ -67,7 +67,7 @@ class ShareShortcutHelper @Inject constructor(
}
// inset the loaded bitmap inside a 108dp transparent canvas so it looks good as adaptive icon
val outBmp = Bitmap.createBitmap(outerSize, outerSize, Bitmap.Config.ARGB_8888)
val outBmp = createBitmap(outerSize, outerSize)
val canvas = Canvas(outBmp)
val borderSize = (outerSize - innerSize) / 2

27
app/src/main/java/com/keylesspalace/tusky/util/StatusDisplayOptions.kt

@ -34,10 +34,6 @@ data class StatusDisplayOptions(
val useBlurhash: Boolean,
@get:JvmName("cardViewMode")
val cardViewMode: CardViewMode,
@get:JvmName("confirmReblogs")
val confirmReblogs: Boolean,
@get:JvmName("confirmFavourites")
val confirmFavourites: Boolean,
@get:JvmName("hideStats")
val hideStats: Boolean,
@get:JvmName("animateEmojis")
@ -69,12 +65,6 @@ data class StatusDisplayOptions(
PrefKeys.USE_BLURHASH -> copy(
useBlurhash = preferences.getBoolean(key, true)
)
PrefKeys.CONFIRM_FAVOURITES -> copy(
confirmFavourites = preferences.getBoolean(key, false)
)
PrefKeys.CONFIRM_REBLOGS -> copy(
confirmReblogs = preferences.getBoolean(key, true)
)
PrefKeys.WELLBEING_HIDE_STATS_POSTS -> copy(
hideStats = preferences.getBoolean(key, false)
)
@ -93,21 +83,6 @@ data class StatusDisplayOptions(
}
companion object {
/** Preference keys that, if changed, affect StatusDisplayOptions */
val prefKeys = setOf(
PrefKeys.ABSOLUTE_TIME_VIEW,
PrefKeys.ALWAYS_SHOW_SENSITIVE_MEDIA,
PrefKeys.ALWAYS_OPEN_SPOILER,
PrefKeys.ANIMATE_CUSTOM_EMOJIS,
PrefKeys.ANIMATE_GIF_AVATARS,
PrefKeys.CONFIRM_FAVOURITES,
PrefKeys.CONFIRM_REBLOGS,
PrefKeys.MEDIA_PREVIEW_ENABLED,
PrefKeys.SHOW_BOT_OVERLAY,
PrefKeys.USE_BLURHASH,
PrefKeys.WELLBEING_HIDE_STATS_POSTS
)
fun from(preferences: SharedPreferences, account: AccountEntity) = StatusDisplayOptions(
animateAvatars = preferences.getBoolean(PrefKeys.ANIMATE_GIF_AVATARS, false),
animateEmojis = preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false),
@ -116,8 +91,6 @@ data class StatusDisplayOptions(
showBotOverlay = preferences.getBoolean(PrefKeys.SHOW_BOT_OVERLAY, true),
useBlurhash = preferences.getBoolean(PrefKeys.USE_BLURHASH, true),
cardViewMode = CardViewMode.NONE,
confirmReblogs = preferences.getBoolean(PrefKeys.CONFIRM_REBLOGS, true),
confirmFavourites = preferences.getBoolean(PrefKeys.CONFIRM_FAVOURITES, false),
hideStats = preferences.getBoolean(PrefKeys.WELLBEING_HIDE_STATS_POSTS, false),
showStatsInline = preferences.getBoolean(PrefKeys.SHOW_STATS_INLINE, false),
showSensitiveMedia = account.alwaysShowSensitiveMedia,

10
app/src/main/java/com/keylesspalace/tusky/util/StatusViewHelper.kt

@ -17,13 +17,13 @@ package com.keylesspalace.tusky.util
import android.content.Context
import android.graphics.Color
import android.graphics.drawable.ColorDrawable
import android.text.InputFilter
import android.text.TextUtils
import android.view.View
import android.widget.ImageView
import android.widget.TextView
import androidx.annotation.DrawableRes
import androidx.core.graphics.drawable.toDrawable
import com.bumptech.glide.Glide
import com.google.android.material.color.MaterialColors
import com.keylesspalace.tusky.R
@ -89,9 +89,8 @@ class StatusViewHelper(private val itemView: View) {
}
val mediaPreviewUnloaded =
ColorDrawable(
MaterialColors.getColor(context, R.attr.colorBackgroundAccent, Color.BLACK)
)
MaterialColors.getColor(context, R.attr.colorBackgroundAccent, Color.BLACK)
.toDrawable()
val n = min(attachments.size, Status.MAX_MEDIA_ATTACHMENTS)
@ -348,7 +347,8 @@ class StatusViewHelper(private val itemView: View) {
options[i].title,
percent,
options[i].voted,
pollResults[i].context
pollResults[i].context,
pollResults[i]
)
pollResults[i].text = pollOptionText.emojify(emojis, pollResults[i], animateEmojis)
pollResults[i].visibility = View.VISIBLE

2
app/src/main/java/com/keylesspalace/tusky/util/StringUtils.kt

@ -9,7 +9,7 @@ import kotlin.random.Random
private const val POSSIBLE_CHARS = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ"
const val HASHTAG_EXPRESSION = "([\\w_]*[\\p{Alpha}_][\\w_]*)"
val hashtagPattern = Pattern.compile(HASHTAG_EXPRESSION, Pattern.CASE_INSENSITIVE or Pattern.MULTILINE)
val hashtagPattern: Pattern = Pattern.compile(HASHTAG_EXPRESSION, Pattern.CASE_INSENSITIVE or Pattern.MULTILINE)
fun randomAlphanumericString(count: Int): String {
val chars = CharArray(count)

7
app/src/main/java/com/keylesspalace/tusky/util/ThemeUtils.kt

@ -22,7 +22,6 @@ import android.graphics.Color
import android.graphics.drawable.Drawable
import androidx.annotation.AttrRes
import androidx.appcompat.app.AppCompatDelegate
import androidx.core.content.res.use
import com.google.android.material.color.MaterialColors
import com.keylesspalace.tusky.settings.AppTheme
@ -31,12 +30,6 @@ import com.keylesspalace.tusky.settings.AppTheme
* the ability to do so is not supported in resource files.
*/
fun getDimension(context: Context, @AttrRes attribute: Int): Int {
return context.obtainStyledAttributes(intArrayOf(attribute)).use { array ->
array.getDimensionPixelSize(0, -1)
}
}
fun setDrawableTint(context: Context, drawable: Drawable, @AttrRes attribute: Int) {
drawable.setTint(MaterialColors.getColor(context, attribute, Color.BLACK))
}

20
app/src/main/java/com/keylesspalace/tusky/util/ViewDataUtils.kt

@ -36,6 +36,7 @@ package com.keylesspalace.tusky.util
import androidx.paging.CombinedLoadStates
import androidx.paging.LoadState
import com.keylesspalace.tusky.entity.Filter
import com.keylesspalace.tusky.entity.Status
import com.keylesspalace.tusky.entity.TrendingTag
import com.keylesspalace.tusky.viewdata.StatusViewData
@ -47,17 +48,16 @@ fun Status.toViewData(
isExpanded: Boolean,
isCollapsed: Boolean,
isDetailed: Boolean = false,
filter: Filter?,
translation: TranslationViewData? = null,
): StatusViewData.Concrete {
return StatusViewData.Concrete(
status = this,
isShowingContent = isShowingContent,
isCollapsed = isCollapsed,
isExpanded = isExpanded,
isDetailed = isDetailed,
translation = translation,
)
}
) = StatusViewData.Concrete(
status = this,
isShowingContent = isShowingContent,
isCollapsed = isCollapsed,
isExpanded = isExpanded,
isDetailed = isDetailed,
translation = translation,
).apply { this.filter = filter }
fun List<TrendingTag>.toViewData(): List<TrendingViewData.Tag> {
val maxTrendingValue = flatMap { tag -> tag.history }

15
app/src/main/java/com/keylesspalace/tusky/view/ClickableSpanTextView.kt

@ -39,6 +39,7 @@ import android.view.MotionEvent.ACTION_DOWN
import android.view.MotionEvent.ACTION_UP
import android.view.ViewConfiguration
import androidx.appcompat.widget.AppCompatTextView
import androidx.core.graphics.withSave
import androidx.core.view.doOnLayout
import com.keylesspalace.tusky.BuildConfig
import com.keylesspalace.tusky.R
@ -379,15 +380,15 @@ class ClickableSpanTextView @JvmOverloads constructor(
// Paint span boundaries. Optimised out on release builds, or debug builds where
// showSpanBoundaries is false.
if (BuildConfig.DEBUG && showSpanBoundaries) {
canvas.save()
for (entry in delegateRects) {
canvas.drawRect(entry.key, paddingDebugPaint)
}
canvas.withSave {
for (entry in delegateRects) {
drawRect(entry.key, paddingDebugPaint)
}
for (entry in spanRects) {
canvas.drawRect(entry.key, spanDebugPaint)
for (entry in spanRects) {
drawRect(entry.key, spanDebugPaint)
}
}
canvas.restore()
}
}

209
app/src/main/java/com/keylesspalace/tusky/view/ConfirmationBottomSheet.kt

@ -0,0 +1,209 @@
package com.keylesspalace.tusky.view
import android.annotation.SuppressLint
import android.content.Context
import android.content.SharedPreferences
import android.content.res.ColorStateList
import android.graphics.drawable.RippleDrawable
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.ArrayAdapter
import android.widget.Filter
import androidx.annotation.DrawableRes
import androidx.annotation.StringRes
import androidx.core.content.edit
import androidx.core.graphics.ColorUtils
import androidx.core.graphics.drawable.toDrawable
import androidx.core.os.bundleOf
import androidx.fragment.app.Fragment
import androidx.fragment.app.setFragmentResult
import com.google.android.material.R as materialR
import com.google.android.material.bottomsheet.BottomSheetDialogFragment
import com.google.android.material.color.MaterialColors
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.databinding.BottomsheetConfirmationBinding
import com.keylesspalace.tusky.databinding.ItemReblogOptionBinding
import com.keylesspalace.tusky.entity.Status
import com.keylesspalace.tusky.settings.PrefKeys
import com.keylesspalace.tusky.util.getNonNullString
import com.keylesspalace.tusky.util.getSerializableCompat
import com.keylesspalace.tusky.util.hide
import com.keylesspalace.tusky.util.viewBinding
import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject
@AndroidEntryPoint
class ConfirmationBottomSheet : BottomSheetDialogFragment(R.layout.bottomsheet_confirmation) {
@Inject
lateinit var prefs: SharedPreferences
private val binding by viewBinding(BottomsheetConfirmationBinding::bind)
private var selectedOption = Status.Visibility.PUBLIC
@SuppressLint("UseCompatTextViewDrawableApis")
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
val mode: Mode = requireArguments().getSerializableCompat(ARG_MODE)!!
if (mode == Mode.REBLOG) {
selectedOption = Status.Visibility.valueOf(prefs.getNonNullString(PrefKeys.REBLOG_PRIVACY, Status.Visibility.PUBLIC.name))
binding.confirmTextView.setText(R.string.reblog_confirm)
binding.confirmTextView.setCompoundDrawablesRelativeWithIntrinsicBounds(R.drawable.ic_repeat_24dp, 0, 0, 0)
binding.confirmTextView.compoundDrawableTintList = ColorStateList.valueOf(
MaterialColors.getColor(binding.confirmTextView, materialR.attr.colorPrimary)
)
binding.confirmButton.setText(R.string.action_reblog)
binding.confirmButton.setOnClickListener {
prefs.edit {
putString(PrefKeys.REBLOG_PRIVACY, selectedOption.name)
}
setFragmentResult(KEY_CONFIRM, bundleOf(RESULT_VISIBILITY to selectedOption.name))
dismiss()
}
binding.reblogPrivacyDropdown.setAdapter(OptionsAdapter(view.context))
binding.reblogPrivacyLayout.setStartIconDrawable(selectedOption.getIcon())
binding.reblogPrivacyDropdown.setText(selectedOption.getName())
binding.reblogPrivacyDropdown.setOnItemClickListener { _, _, position, _ ->
selectedOption = reblogOptions.getOrElse(position) { Status.Visibility.PUBLIC }
binding.reblogPrivacyLayout.setStartIconDrawable(selectedOption.getIcon())
binding.reblogPrivacyDropdown.setText(selectedOption.getName())
}
} else {
binding.confirmTextView.setText(R.string.favourite_confirm)
binding.confirmTextView.setCompoundDrawablesRelativeWithIntrinsicBounds(R.drawable.ic_star_24dp, 0, 0, 0)
binding.confirmTextView.compoundDrawableTintList = ColorStateList.valueOf(
requireContext().getColor(R.color.favoriteButtonActiveColor)
)
binding.reblogPrivacyLayout.hide()
binding.confirmButton.setText(R.string.action_favourite)
binding.confirmButton.setOnClickListener {
setFragmentResult(KEY_CONFIRM, bundleOf())
dismiss()
}
}
binding.cancelButton.setOnClickListener {
dismiss()
}
}
inner class OptionsAdapter(context: Context) : ArrayAdapter<Status.Visibility>(context, R.layout.item_reblog_option, reblogOptions) {
override fun getView(position: Int, convertView: View?, parent: ViewGroup): View {
val item = getItem(position)
val view: View = convertView ?: run {
val layoutInflater = LayoutInflater.from(parent.context)
val binding = ItemReblogOptionBinding.inflate(layoutInflater)
binding.reblogOptionName.setText(item.getName())
binding.reblogOptionDescription.setText(item.getDescription())
binding.reblogOptionIcon.setImageResource(item.getIcon())
binding.root
}
if (item == selectedOption) {
// using the same color as MaterialAutoCompleteTextView.MaterialArrayAdapter which is not public unfortunately
val overlayColor = ColorUtils.setAlphaComponent(
MaterialColors.getColor(view, materialR.attr.colorOnSurface),
30
)
view.background = RippleDrawable(
ColorStateList.valueOf(overlayColor),
MaterialColors.getColor(view, materialR.attr.colorSecondaryContainer).toDrawable(),
null
)
} else {
view.background = null
}
return view
}
override fun getFilter() = object : Filter() {
override fun performFiltering(constraint: CharSequence) = FilterResults().apply { count = 3 }
override fun publishResults(constraint: CharSequence, results: FilterResults) {
// noop
}
}
}
enum class Mode {
REBLOG,
FAVOURITE
}
companion object {
private const val TAG = "ConfirmationBottomSheet"
private const val KEY_CONFIRM = "confirm"
private const val ARG_MODE = "mode"
private const val RESULT_VISIBILITY = "visibility"
private val reblogOptions = listOf(Status.Visibility.PUBLIC, Status.Visibility.UNLISTED, Status.Visibility.PRIVATE)
fun Fragment.confirmReblog(preferences: SharedPreferences, onConfirmed: (Status.Visibility) -> Unit) {
if (preferences.getBoolean(PrefKeys.CONFIRM_REBLOGS, true)) {
val bottomSheet = ConfirmationBottomSheet()
bottomSheet.arguments = bundleOf(
ARG_MODE to Mode.REBLOG
)
bottomSheet.show(childFragmentManager, TAG)
childFragmentManager.setFragmentResultListener(KEY_CONFIRM, this) { requestKey, result ->
onConfirmed(Status.Visibility.valueOf(result.getString(RESULT_VISIBILITY)!!))
}
} else {
onConfirmed(Status.Visibility.PUBLIC)
}
}
fun Fragment.confirmFavourite(preferences: SharedPreferences, onConfirmed: () -> Unit) {
if (preferences.getBoolean(PrefKeys.CONFIRM_FAVOURITES, true)) {
val bottomSheet = ConfirmationBottomSheet()
bottomSheet.arguments = bundleOf(
ARG_MODE to Mode.FAVOURITE
)
bottomSheet.show(childFragmentManager, TAG)
childFragmentManager.setFragmentResultListener(KEY_CONFIRM, this) { _, _ ->
onConfirmed()
}
} else {
onConfirmed()
}
}
@StringRes
private fun Status.Visibility?.getName(): Int {
return when (this) {
Status.Visibility.PUBLIC -> R.string.post_privacy_public
Status.Visibility.UNLISTED -> R.string.post_privacy_unlisted
else -> R.string.post_privacy_followers_only
}
}
@StringRes
private fun Status.Visibility?.getDescription(): Int {
return when (this) {
Status.Visibility.PUBLIC -> R.string.reblog_privacy_public_description
Status.Visibility.UNLISTED -> R.string.reblog_privacy_unlisted_description
else -> R.string.reblog_privacy_followers_only_description
}
}
@DrawableRes
private fun Status.Visibility?.getIcon(): Int {
return when (this) {
Status.Visibility.PUBLIC -> R.drawable.ic_public_24dp
Status.Visibility.UNLISTED -> R.drawable.ic_lock_open_24dp
else -> R.drawable.ic_lock_24dp
}
}
}
}

48
app/src/main/java/com/keylesspalace/tusky/view/EndlessOnScrollListener.kt

@ -1,48 +0,0 @@
/* Copyright 2017 Andrew Dawson
*
* This file is a part of Tusky.
*
* This program is free software; you can redistribute it and/or modify it under the terms of the
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
* License, or (at your option) any later version.
*
* Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
* Public License for more details.
*
* You should have received a copy of the GNU General Public License along with Tusky; if not,
* see <http://www.gnu.org/licenses>. */
package com.keylesspalace.tusky.view
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
abstract class EndlessOnScrollListener(private val layoutManager: LinearLayoutManager) :
RecyclerView.OnScrollListener() {
private var previousTotalItemCount = 0
override fun onScrolled(view: RecyclerView, dx: Int, dy: Int) {
val totalItemCount = layoutManager.itemCount
val lastVisibleItemPosition = layoutManager.findLastVisibleItemPosition()
if (totalItemCount < previousTotalItemCount) {
previousTotalItemCount = totalItemCount
}
if (totalItemCount != previousTotalItemCount) {
previousTotalItemCount = totalItemCount
}
if (lastVisibleItemPosition + VISIBLE_THRESHOLD > totalItemCount) {
onLoadMore(totalItemCount, view)
}
}
fun reset() {
previousTotalItemCount = 0
}
abstract fun onLoadMore(totalItemsCount: Int, view: RecyclerView)
companion object {
private const val VISIBLE_THRESHOLD = 15
}
}

4
app/src/main/java/com/keylesspalace/tusky/view/GraphView.kt

@ -21,12 +21,12 @@ import android.graphics.Paint
import android.graphics.Path
import android.graphics.PathMeasure
import android.graphics.Rect
import android.text.TextUtils
import android.util.AttributeSet
import android.view.View
import androidx.annotation.ColorInt
import androidx.annotation.Dimension
import androidx.core.content.res.use
import androidx.core.text.layoutDirection
import com.google.android.material.R as materialR
import com.google.android.material.color.MaterialColors
import com.keylesspalace.tusky.R
@ -103,7 +103,7 @@ class GraphView @JvmOverloads constructor(
init {
initFromXML(attrs)
isRtlLayout = TextUtils.getLayoutDirectionFromLocale(Locale.getDefault()) == LAYOUT_DIRECTION_RTL
isRtlLayout = Locale.getDefault().layoutDirection == LAYOUT_DIRECTION_RTL
}
private fun initFromXML(attr: AttributeSet?) {

4
app/src/main/java/com/keylesspalace/tusky/viewdata/NotificationViewData.kt

@ -25,7 +25,7 @@ sealed class NotificationViewData {
abstract val id: String
abstract fun asStatusOrNull(): StatusViewData.Concrete?
abstract fun asPlaceholderOrNull(): Placeholder?
abstract fun asPlaceholderOrNull(): LoadMore?
data class Concrete(
override val id: String,
@ -41,7 +41,7 @@ sealed class NotificationViewData {
override fun asPlaceholderOrNull() = null
}
data class Placeholder(
data class LoadMore(
override val id: String,
val isLoading: Boolean
) : NotificationViewData() {

16
app/src/main/java/com/keylesspalace/tusky/viewdata/PollViewData.kt

@ -16,9 +16,15 @@
package com.keylesspalace.tusky.viewdata
import android.content.Context
import android.text.Spannable
import android.text.SpannableStringBuilder
import android.text.Spanned
import android.text.style.DynamicDrawableSpan
import android.text.style.ImageSpan
import android.widget.TextView
import androidx.appcompat.content.res.AppCompatResources
import androidx.core.text.parseAsHtml
import com.google.android.material.color.MaterialColors
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.entity.Poll
import com.keylesspalace.tusky.entity.PollOption
@ -52,13 +58,21 @@ fun calculatePercent(fraction: Int?, totalVoters: Int?, totalVotes: Int): Int {
}
}
fun buildDescription(title: String, percent: Int, voted: Boolean, context: Context): Spanned {
fun buildDescription(title: String, percent: Int, voted: Boolean, context: Context, textView: TextView? = null): Spanned {
val builder =
SpannableStringBuilder(
context.getString(R.string.poll_percent_format, percent).parseAsHtml()
)
if (voted) {
builder.append("")
if (textView != null) {
val size = (textView.textSize * 1.1).toInt()
val drawable = AppCompatResources.getDrawable(context, R.drawable.ic_check_circle_24dp)!!
drawable.setBounds(0, 0, size, size)
drawable.setTint(MaterialColors.getColor(textView, android.R.attr.textColorPrimary))
builder.setSpan(ImageSpan(drawable, DynamicDrawableSpan.ALIGN_CENTER), builder.length - 2, builder.length - 1, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
}
} else {
builder.append(" ")
}

8
app/src/main/java/com/keylesspalace/tusky/viewdata/StatusViewData.kt

@ -38,11 +38,11 @@ sealed interface TranslationViewData {
* Created by charlag on 11/07/2017.
*
* Class to represent data required to display either a notification or a placeholder.
* It is either a [StatusViewData.Concrete] or a [StatusViewData.Placeholder].
* It is either a [StatusViewData.Concrete] or a [StatusViewData.LoadMore].
*/
sealed class StatusViewData {
abstract val id: String
var filterAction: Filter.Action = Filter.Action.NONE
var filter: Filter? = null
data class Concrete(
val status: Status,
@ -133,12 +133,12 @@ sealed class StatusViewData {
}
}
data class Placeholder(
data class LoadMore(
override val id: String,
val isLoading: Boolean
) : StatusViewData()
fun asStatusOrNull() = this as? Concrete
fun asPlaceholderOrNull() = this as? Placeholder
fun asPlaceholderOrNull() = this as? LoadMore
}

12
app/src/main/res/anim/explode.xml

@ -1,12 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<set xmlns:android="http://schemas.android.com/apk/res/android" >
<scale
android:duration="300"
android:fromXScale="0"
android:fromYScale="0"
android:pivotX="50%"
android:pivotY="50%"
android:toXScale="1"
android:toYScale="1" >
</scale>
</set>

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

Loading…
Cancel
Save