Refactor notifications to Kotlin & paging (#4026)
This refactors the NotificationsFragment and related classes to Kotlin & paging. While trying to preserve as much of the original behavior as possible, this adds the following improvements as well: - The "show notifications filter" preference was added again - The "load more" button now has a background ripple effect when clicked - The "legal" report category of Mastodon 4.2 is now supported in report notifications - Unknown notifications now display "unknown notification type" instead of an empty line Other code quality improvements: - All views from xml layouts are now referenced via ViewBindings - the classes responsible for showing system notifications were moved to a new package `systemnotifications` while the classes from this refactoring are in `notifications` - the id of the local Tusky account is now called `tuskyAccountId` in all places I could find closes https://github.com/tuskyapp/Tusky/issues/3429 --------- Co-authored-by: Zongle Wang <wangzongler@gmail.com>
This commit is contained in:
parent
3bbf96b057
commit
b2c0b18c8e
|
@ -1,5 +1,5 @@
|
||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<issues format="6" by="lint 8.3.1" type="baseline" client="gradle" dependencies="false" name="AGP (8.3.1)" variant="all" version="8.3.1">
|
<issues format="6" by="lint 8.3.2" type="baseline" client="gradle" dependencies="false" name="AGP (8.3.2)" variant="all" version="8.3.2">
|
||||||
|
|
||||||
<issue
|
<issue
|
||||||
id="GestureBackNavigation"
|
id="GestureBackNavigation"
|
||||||
|
@ -53,14 +53,14 @@
|
||||||
|
|
||||||
<issue
|
<issue
|
||||||
id="PrivateResource"
|
id="PrivateResource"
|
||||||
message="Overriding `@layout/exo_player_control_view` which is marked as private in androidx.media3:media3-ui:1.3.0. If deliberate, use tools:override="true", otherwise pick a different name.">
|
message="Overriding `@layout/exo_player_control_view` which is marked as private in androidx.media3:media3-ui:1.3.1. If deliberate, use tools:override="true", otherwise pick a different name.">
|
||||||
<location
|
<location
|
||||||
file="src/main/res/layout/exo_player_control_view.xml"/>
|
file="src/main/res/layout/exo_player_control_view.xml"/>
|
||||||
</issue>
|
</issue>
|
||||||
|
|
||||||
<issue
|
<issue
|
||||||
id="PrivateResource"
|
id="PrivateResource"
|
||||||
message="The resource `@color/exo_bottom_bar_background` is marked as private in androidx.media3:media3-ui:1.3.0"
|
message="The resource `@color/exo_bottom_bar_background` is marked as private in androidx.media3:media3-ui:1.3.1"
|
||||||
errorLine1=" android:background="@color/exo_bottom_bar_background""
|
errorLine1=" android:background="@color/exo_bottom_bar_background""
|
||||||
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
|
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
|
||||||
<location
|
<location
|
||||||
|
@ -71,7 +71,7 @@
|
||||||
|
|
||||||
<issue
|
<issue
|
||||||
id="PrivateResource"
|
id="PrivateResource"
|
||||||
message="The resource `@dimen/exo_styled_controls_padding` is marked as private in androidx.media3:media3-ui:1.3.0"
|
message="The resource `@dimen/exo_styled_controls_padding` is marked as private in androidx.media3:media3-ui:1.3.1"
|
||||||
errorLine1=" android:padding="@dimen/exo_styled_controls_padding""
|
errorLine1=" android:padding="@dimen/exo_styled_controls_padding""
|
||||||
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
|
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
|
||||||
<location
|
<location
|
||||||
|
@ -82,7 +82,7 @@
|
||||||
|
|
||||||
<issue
|
<issue
|
||||||
id="PrivateResource"
|
id="PrivateResource"
|
||||||
message="The resource `@layout/exo_player_control_rewind_button` is marked as private in androidx.media3:media3-ui:1.3.0"
|
message="The resource `@layout/exo_player_control_rewind_button` is marked as private in androidx.media3:media3-ui:1.3.1"
|
||||||
errorLine1=" <include layout="@layout/exo_player_control_rewind_button" />"
|
errorLine1=" <include layout="@layout/exo_player_control_rewind_button" />"
|
||||||
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
|
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
|
||||||
<location
|
<location
|
||||||
|
@ -93,7 +93,7 @@
|
||||||
|
|
||||||
<issue
|
<issue
|
||||||
id="PrivateResource"
|
id="PrivateResource"
|
||||||
message="The resource `@layout/exo_player_control_ffwd_button` is marked as private in androidx.media3:media3-ui:1.3.0"
|
message="The resource `@layout/exo_player_control_ffwd_button` is marked as private in androidx.media3:media3-ui:1.3.1"
|
||||||
errorLine1=" <include layout="@layout/exo_player_control_ffwd_button" />"
|
errorLine1=" <include layout="@layout/exo_player_control_ffwd_button" />"
|
||||||
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
|
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
|
||||||
<location
|
<location
|
||||||
|
@ -104,7 +104,7 @@
|
||||||
|
|
||||||
<issue
|
<issue
|
||||||
id="PrivateResource"
|
id="PrivateResource"
|
||||||
message="The resource `@dimen/exo_styled_bottom_bar_height` is marked as private in androidx.media3:media3-ui:1.3.0"
|
message="The resource `@dimen/exo_styled_bottom_bar_height` is marked as private in androidx.media3:media3-ui:1.3.1"
|
||||||
errorLine1=" android:layout_height="@dimen/exo_styled_bottom_bar_height""
|
errorLine1=" android:layout_height="@dimen/exo_styled_bottom_bar_height""
|
||||||
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
|
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
|
||||||
<location
|
<location
|
||||||
|
@ -115,7 +115,7 @@
|
||||||
|
|
||||||
<issue
|
<issue
|
||||||
id="PrivateResource"
|
id="PrivateResource"
|
||||||
message="The resource `@dimen/exo_styled_bottom_bar_margin_top` is marked as private in androidx.media3:media3-ui:1.3.0"
|
message="The resource `@dimen/exo_styled_bottom_bar_margin_top` is marked as private in androidx.media3:media3-ui:1.3.1"
|
||||||
errorLine1=" android:layout_marginTop="@dimen/exo_styled_bottom_bar_margin_top""
|
errorLine1=" android:layout_marginTop="@dimen/exo_styled_bottom_bar_margin_top""
|
||||||
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
|
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
|
||||||
<location
|
<location
|
||||||
|
@ -126,7 +126,7 @@
|
||||||
|
|
||||||
<issue
|
<issue
|
||||||
id="PrivateResource"
|
id="PrivateResource"
|
||||||
message="The resource `@color/exo_bottom_bar_background` is marked as private in androidx.media3:media3-ui:1.3.0"
|
message="The resource `@color/exo_bottom_bar_background` is marked as private in androidx.media3:media3-ui:1.3.1"
|
||||||
errorLine1=" android:background="@color/exo_bottom_bar_background""
|
errorLine1=" android:background="@color/exo_bottom_bar_background""
|
||||||
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
|
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
|
||||||
<location
|
<location
|
||||||
|
@ -137,7 +137,7 @@
|
||||||
|
|
||||||
<issue
|
<issue
|
||||||
id="PrivateResource"
|
id="PrivateResource"
|
||||||
message="The resource `@dimen/exo_styled_bottom_bar_time_padding` is marked as private in androidx.media3:media3-ui:1.3.0"
|
message="The resource `@dimen/exo_styled_bottom_bar_time_padding` is marked as private in androidx.media3:media3-ui:1.3.1"
|
||||||
errorLine1=" android:paddingStart="@dimen/exo_styled_bottom_bar_time_padding""
|
errorLine1=" android:paddingStart="@dimen/exo_styled_bottom_bar_time_padding""
|
||||||
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
|
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
|
||||||
<location
|
<location
|
||||||
|
@ -148,7 +148,7 @@
|
||||||
|
|
||||||
<issue
|
<issue
|
||||||
id="PrivateResource"
|
id="PrivateResource"
|
||||||
message="The resource `@dimen/exo_styled_bottom_bar_time_padding` is marked as private in androidx.media3:media3-ui:1.3.0"
|
message="The resource `@dimen/exo_styled_bottom_bar_time_padding` is marked as private in androidx.media3:media3-ui:1.3.1"
|
||||||
errorLine1=" android:paddingEnd="@dimen/exo_styled_bottom_bar_time_padding""
|
errorLine1=" android:paddingEnd="@dimen/exo_styled_bottom_bar_time_padding""
|
||||||
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
|
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
|
||||||
<location
|
<location
|
||||||
|
@ -159,7 +159,7 @@
|
||||||
|
|
||||||
<issue
|
<issue
|
||||||
id="PrivateResource"
|
id="PrivateResource"
|
||||||
message="The resource `@dimen/exo_styled_bottom_bar_time_padding` is marked as private in androidx.media3:media3-ui:1.3.0"
|
message="The resource `@dimen/exo_styled_bottom_bar_time_padding` is marked as private in androidx.media3:media3-ui:1.3.1"
|
||||||
errorLine1=" android:paddingLeft="@dimen/exo_styled_bottom_bar_time_padding""
|
errorLine1=" android:paddingLeft="@dimen/exo_styled_bottom_bar_time_padding""
|
||||||
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
|
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
|
||||||
<location
|
<location
|
||||||
|
@ -170,7 +170,7 @@
|
||||||
|
|
||||||
<issue
|
<issue
|
||||||
id="PrivateResource"
|
id="PrivateResource"
|
||||||
message="The resource `@dimen/exo_styled_bottom_bar_time_padding` is marked as private in androidx.media3:media3-ui:1.3.0"
|
message="The resource `@dimen/exo_styled_bottom_bar_time_padding` is marked as private in androidx.media3:media3-ui:1.3.1"
|
||||||
errorLine1=" android:paddingRight="@dimen/exo_styled_bottom_bar_time_padding""
|
errorLine1=" android:paddingRight="@dimen/exo_styled_bottom_bar_time_padding""
|
||||||
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
|
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
|
||||||
<location
|
<location
|
||||||
|
@ -181,7 +181,7 @@
|
||||||
|
|
||||||
<issue
|
<issue
|
||||||
id="PrivateResource"
|
id="PrivateResource"
|
||||||
message="The resource `@dimen/exo_styled_progress_layout_height` is marked as private in androidx.media3:media3-ui:1.3.0"
|
message="The resource `@dimen/exo_styled_progress_layout_height` is marked as private in androidx.media3:media3-ui:1.3.1"
|
||||||
errorLine1=" android:layout_height="@dimen/exo_styled_progress_layout_height""
|
errorLine1=" android:layout_height="@dimen/exo_styled_progress_layout_height""
|
||||||
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
|
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
|
||||||
<location
|
<location
|
||||||
|
@ -192,7 +192,7 @@
|
||||||
|
|
||||||
<issue
|
<issue
|
||||||
id="PrivateResource"
|
id="PrivateResource"
|
||||||
message="The resource `@dimen/exo_styled_progress_margin_bottom` is marked as private in androidx.media3:media3-ui:1.3.0"
|
message="The resource `@dimen/exo_styled_progress_margin_bottom` is marked as private in androidx.media3:media3-ui:1.3.1"
|
||||||
errorLine1=" android:layout_marginBottom="@dimen/exo_styled_progress_margin_bottom"/>"
|
errorLine1=" android:layout_marginBottom="@dimen/exo_styled_progress_margin_bottom"/>"
|
||||||
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
|
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
|
||||||
<location
|
<location
|
||||||
|
@ -203,7 +203,7 @@
|
||||||
|
|
||||||
<issue
|
<issue
|
||||||
id="PrivateResource"
|
id="PrivateResource"
|
||||||
message="The resource `@dimen/exo_styled_minimal_controls_margin_bottom` is marked as private in androidx.media3:media3-ui:1.3.0"
|
message="The resource `@dimen/exo_styled_minimal_controls_margin_bottom` is marked as private in androidx.media3:media3-ui:1.3.1"
|
||||||
errorLine1=" android:layout_marginBottom="@dimen/exo_styled_minimal_controls_margin_bottom""
|
errorLine1=" android:layout_marginBottom="@dimen/exo_styled_minimal_controls_margin_bottom""
|
||||||
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
|
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
|
||||||
<location
|
<location
|
||||||
|
@ -498,28 +498,6 @@
|
||||||
column="13"/>
|
column="13"/>
|
||||||
</issue>
|
</issue>
|
||||||
|
|
||||||
<issue
|
|
||||||
id="StringFormatTrivial"
|
|
||||||
message="This formatting string is trivial. Rather than using `String.format` to create your String, it will be more performant to concatenate your arguments with `+`. "
|
|
||||||
errorLine1=" (error) -> Log.e(TAG, String.format("Failed to %s account id %s", accept ? "accept" : "reject", id))"
|
|
||||||
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
|
|
||||||
<location
|
|
||||||
file="src/main/java/com/keylesspalace/tusky/fragment/NotificationsFragment.java"
|
|
||||||
line="808"
|
|
||||||
column="49"/>
|
|
||||||
</issue>
|
|
||||||
|
|
||||||
<issue
|
|
||||||
id="StringFormatTrivial"
|
|
||||||
message="This formatting string is trivial. Rather than using `String.format` to create your String, it will be more performant to concatenate your arguments with `+`. "
|
|
||||||
errorLine1=" LinkHelper.openLink(requireContext(), String.format("https://%s/admin/reports/%s", accountManager.getActiveAccount().getDomain(), reportId));"
|
|
||||||
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
|
|
||||||
<location
|
|
||||||
file="src/main/java/com/keylesspalace/tusky/fragment/NotificationsFragment.java"
|
|
||||||
line="829"
|
|
||||||
column="61"/>
|
|
||||||
</issue>
|
|
||||||
|
|
||||||
<issue
|
<issue
|
||||||
id="SmallSp"
|
id="SmallSp"
|
||||||
message="Avoid using sizes smaller than `11sp`: `8sp`"
|
message="Avoid using sizes smaller than `11sp`: `8sp`"
|
||||||
|
@ -905,279 +883,4 @@
|
||||||
column="9"/>
|
column="9"/>
|
||||||
</issue>
|
</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=" public abstract boolean deepEquals(NotificationViewData other);"
|
|
||||||
errorLine2=" ~~~~~~~~~~~~~~~~~~~~">
|
|
||||||
<location
|
|
||||||
file="src/main/java/com/keylesspalace/tusky/viewdata/NotificationViewData.java"
|
|
||||||
line="43"
|
|
||||||
column="40"/>
|
|
||||||
</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=" public Concrete(Notification.Type type, String id, TimelineAccount account,"
|
|
||||||
errorLine2=" ~~~~~~~~~~~~~~~~~">
|
|
||||||
<location
|
|
||||||
file="src/main/java/com/keylesspalace/tusky/viewdata/NotificationViewData.java"
|
|
||||||
line="54"
|
|
||||||
column="25"/>
|
|
||||||
</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=" public Concrete(Notification.Type type, String id, TimelineAccount account,"
|
|
||||||
errorLine2=" ~~~~~~">
|
|
||||||
<location
|
|
||||||
file="src/main/java/com/keylesspalace/tusky/viewdata/NotificationViewData.java"
|
|
||||||
line="54"
|
|
||||||
column="49"/>
|
|
||||||
</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=" public Concrete(Notification.Type type, String id, TimelineAccount account,"
|
|
||||||
errorLine2=" ~~~~~~~~~~~~~~~">
|
|
||||||
<location
|
|
||||||
file="src/main/java/com/keylesspalace/tusky/viewdata/NotificationViewData.java"
|
|
||||||
line="54"
|
|
||||||
column="60"/>
|
|
||||||
</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=" public Notification.Type getType() {"
|
|
||||||
errorLine2=" ~~~~~~~~~~~~~~~~~">
|
|
||||||
<location
|
|
||||||
file="src/main/java/com/keylesspalace/tusky/viewdata/NotificationViewData.java"
|
|
||||||
line="63"
|
|
||||||
column="16"/>
|
|
||||||
</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=" public String getId() {"
|
|
||||||
errorLine2=" ~~~~~~">
|
|
||||||
<location
|
|
||||||
file="src/main/java/com/keylesspalace/tusky/viewdata/NotificationViewData.java"
|
|
||||||
line="67"
|
|
||||||
column="16"/>
|
|
||||||
</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=" public TimelineAccount getAccount() {"
|
|
||||||
errorLine2=" ~~~~~~~~~~~~~~~">
|
|
||||||
<location
|
|
||||||
file="src/main/java/com/keylesspalace/tusky/viewdata/NotificationViewData.java"
|
|
||||||
line="71"
|
|
||||||
column="16"/>
|
|
||||||
</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=" public boolean deepEquals(NotificationViewData o) {"
|
|
||||||
errorLine2=" ~~~~~~~~~~~~~~~~~~~~">
|
|
||||||
<location
|
|
||||||
file="src/main/java/com/keylesspalace/tusky/viewdata/NotificationViewData.java"
|
|
||||||
line="91"
|
|
||||||
column="35"/>
|
|
||||||
</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=" public Concrete copyWithStatus(@Nullable StatusViewData.Concrete statusViewData) {"
|
|
||||||
errorLine2=" ~~~~~~~~">
|
|
||||||
<location
|
|
||||||
file="src/main/java/com/keylesspalace/tusky/viewdata/NotificationViewData.java"
|
|
||||||
line="108"
|
|
||||||
column="16"/>
|
|
||||||
</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=" public boolean deepEquals(NotificationViewData other) {"
|
|
||||||
errorLine2=" ~~~~~~~~~~~~~~~~~~~~">
|
|
||||||
<location
|
|
||||||
file="src/main/java/com/keylesspalace/tusky/viewdata/NotificationViewData.java"
|
|
||||||
line="132"
|
|
||||||
column="35"/>
|
|
||||||
</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=" public NotificationsAdapter(String accountId,"
|
|
||||||
errorLine2=" ~~~~~~">
|
|
||||||
<location
|
|
||||||
file="src/main/java/com/keylesspalace/tusky/adapter/NotificationsAdapter.java"
|
|
||||||
line="98"
|
|
||||||
column="33"/>
|
|
||||||
</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=" AdapterDataSource<NotificationViewData> dataSource,"
|
|
||||||
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
|
|
||||||
<location
|
|
||||||
file="src/main/java/com/keylesspalace/tusky/adapter/NotificationsAdapter.java"
|
|
||||||
line="99"
|
|
||||||
column="33"/>
|
|
||||||
</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=" StatusDisplayOptions statusDisplayOptions,"
|
|
||||||
errorLine2=" ~~~~~~~~~~~~~~~~~~~~">
|
|
||||||
<location
|
|
||||||
file="src/main/java/com/keylesspalace/tusky/adapter/NotificationsAdapter.java"
|
|
||||||
line="100"
|
|
||||||
column="33"/>
|
|
||||||
</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=" StatusActionListener statusListener,"
|
|
||||||
errorLine2=" ~~~~~~~~~~~~~~~~~~~~">
|
|
||||||
<location
|
|
||||||
file="src/main/java/com/keylesspalace/tusky/adapter/NotificationsAdapter.java"
|
|
||||||
line="101"
|
|
||||||
column="33"/>
|
|
||||||
</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=" NotificationActionListener notificationActionListener,"
|
|
||||||
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~">
|
|
||||||
<location
|
|
||||||
file="src/main/java/com/keylesspalace/tusky/adapter/NotificationsAdapter.java"
|
|
||||||
line="102"
|
|
||||||
column="33"/>
|
|
||||||
</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=" AccountActionListener accountActionListener) {"
|
|
||||||
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~">
|
|
||||||
<location
|
|
||||||
file="src/main/java/com/keylesspalace/tusky/adapter/NotificationsAdapter.java"
|
|
||||||
line="103"
|
|
||||||
column="33"/>
|
|
||||||
</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=" void onViewAccount(String id);"
|
|
||||||
errorLine2=" ~~~~~~">
|
|
||||||
<location
|
|
||||||
file="src/main/java/com/keylesspalace/tusky/adapter/NotificationsAdapter.java"
|
|
||||||
line="340"
|
|
||||||
column="28"/>
|
|
||||||
</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=" void onViewStatusForNotificationId(String notificationId);"
|
|
||||||
errorLine2=" ~~~~~~">
|
|
||||||
<location
|
|
||||||
file="src/main/java/com/keylesspalace/tusky/adapter/NotificationsAdapter.java"
|
|
||||||
line="342"
|
|
||||||
column="44"/>
|
|
||||||
</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=" void onViewReport(String reportId);"
|
|
||||||
errorLine2=" ~~~~~~">
|
|
||||||
<location
|
|
||||||
file="src/main/java/com/keylesspalace/tusky/adapter/NotificationsAdapter.java"
|
|
||||||
line="344"
|
|
||||||
column="27"/>
|
|
||||||
</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=" public static NotificationsFragment newInstance() {"
|
|
||||||
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~">
|
|
||||||
<location
|
|
||||||
file="src/main/java/com/keylesspalace/tusky/fragment/NotificationsFragment.java"
|
|
||||||
line="196"
|
|
||||||
column="19"/>
|
|
||||||
</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=" public void onMute(boolean mute, String id, int position, boolean notifications) {"
|
|
||||||
errorLine2=" ~~~~~~">
|
|
||||||
<location
|
|
||||||
file="src/main/java/com/keylesspalace/tusky/fragment/NotificationsFragment.java"
|
|
||||||
line="791"
|
|
||||||
column="38"/>
|
|
||||||
</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=" public void onBlock(boolean block, String id, int position) {"
|
|
||||||
errorLine2=" ~~~~~~">
|
|
||||||
<location
|
|
||||||
file="src/main/java/com/keylesspalace/tusky/fragment/NotificationsFragment.java"
|
|
||||||
line="796"
|
|
||||||
column="40"/>
|
|
||||||
</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=" public void onRespondToFollowRequest(boolean accept, String id, int position) {"
|
|
||||||
errorLine2=" ~~~~~~">
|
|
||||||
<location
|
|
||||||
file="src/main/java/com/keylesspalace/tusky/fragment/NotificationsFragment.java"
|
|
||||||
line="801"
|
|
||||||
column="58"/>
|
|
||||||
</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=" public void onViewStatusForNotificationId(String notificationId) {"
|
|
||||||
errorLine2=" ~~~~~~">
|
|
||||||
<location
|
|
||||||
file="src/main/java/com/keylesspalace/tusky/fragment/NotificationsFragment.java"
|
|
||||||
line="813"
|
|
||||||
column="47"/>
|
|
||||||
</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=" public void onViewReport(String reportId) {"
|
|
||||||
errorLine2=" ~~~~~~">
|
|
||||||
<location
|
|
||||||
file="src/main/java/com/keylesspalace/tusky/fragment/NotificationsFragment.java"
|
|
||||||
line="828"
|
|
||||||
column="30"/>
|
|
||||||
</issue>
|
|
||||||
|
|
||||||
</issues>
|
</issues>
|
||||||
|
|
File diff suppressed because it is too large
Load Diff
|
@ -42,7 +42,7 @@ import com.google.android.material.color.MaterialColors;
|
||||||
import com.google.android.material.snackbar.Snackbar;
|
import com.google.android.material.snackbar.Snackbar;
|
||||||
import com.keylesspalace.tusky.adapter.AccountSelectionAdapter;
|
import com.keylesspalace.tusky.adapter.AccountSelectionAdapter;
|
||||||
import com.keylesspalace.tusky.components.login.LoginActivity;
|
import com.keylesspalace.tusky.components.login.LoginActivity;
|
||||||
import com.keylesspalace.tusky.db.AccountEntity;
|
import com.keylesspalace.tusky.db.entity.AccountEntity;
|
||||||
import com.keylesspalace.tusky.db.AccountManager;
|
import com.keylesspalace.tusky.db.AccountManager;
|
||||||
import com.keylesspalace.tusky.di.Injectable;
|
import com.keylesspalace.tusky.di.Injectable;
|
||||||
import com.keylesspalace.tusky.interfaces.AccountSelectionListener;
|
import com.keylesspalace.tusky.interfaces.AccountSelectionListener;
|
||||||
|
|
|
@ -80,17 +80,17 @@ import com.keylesspalace.tusky.components.compose.ComposeActivity
|
||||||
import com.keylesspalace.tusky.components.compose.ComposeActivity.Companion.canHandleMimeType
|
import com.keylesspalace.tusky.components.compose.ComposeActivity.Companion.canHandleMimeType
|
||||||
import com.keylesspalace.tusky.components.drafts.DraftsActivity
|
import com.keylesspalace.tusky.components.drafts.DraftsActivity
|
||||||
import com.keylesspalace.tusky.components.login.LoginActivity
|
import com.keylesspalace.tusky.components.login.LoginActivity
|
||||||
import com.keylesspalace.tusky.components.notifications.NotificationHelper
|
|
||||||
import com.keylesspalace.tusky.components.notifications.disableAllNotifications
|
|
||||||
import com.keylesspalace.tusky.components.notifications.enablePushNotificationsWithFallback
|
|
||||||
import com.keylesspalace.tusky.components.notifications.showMigrationNoticeIfNecessary
|
|
||||||
import com.keylesspalace.tusky.components.preference.PreferencesActivity
|
import com.keylesspalace.tusky.components.preference.PreferencesActivity
|
||||||
import com.keylesspalace.tusky.components.scheduled.ScheduledStatusActivity
|
import com.keylesspalace.tusky.components.scheduled.ScheduledStatusActivity
|
||||||
import com.keylesspalace.tusky.components.search.SearchActivity
|
import com.keylesspalace.tusky.components.search.SearchActivity
|
||||||
|
import com.keylesspalace.tusky.components.systemnotifications.NotificationHelper
|
||||||
|
import com.keylesspalace.tusky.components.systemnotifications.disableAllNotifications
|
||||||
|
import com.keylesspalace.tusky.components.systemnotifications.enablePushNotificationsWithFallback
|
||||||
|
import com.keylesspalace.tusky.components.systemnotifications.showMigrationNoticeIfNecessary
|
||||||
import com.keylesspalace.tusky.components.trending.TrendingActivity
|
import com.keylesspalace.tusky.components.trending.TrendingActivity
|
||||||
import com.keylesspalace.tusky.databinding.ActivityMainBinding
|
import com.keylesspalace.tusky.databinding.ActivityMainBinding
|
||||||
import com.keylesspalace.tusky.db.AccountEntity
|
|
||||||
import com.keylesspalace.tusky.db.DraftsAlert
|
import com.keylesspalace.tusky.db.DraftsAlert
|
||||||
|
import com.keylesspalace.tusky.db.entity.AccountEntity
|
||||||
import com.keylesspalace.tusky.di.ApplicationScope
|
import com.keylesspalace.tusky.di.ApplicationScope
|
||||||
import com.keylesspalace.tusky.entity.Account
|
import com.keylesspalace.tusky.entity.Account
|
||||||
import com.keylesspalace.tusky.entity.Notification
|
import com.keylesspalace.tusky.entity.Notification
|
||||||
|
|
|
@ -20,10 +20,10 @@ import androidx.annotation.DrawableRes
|
||||||
import androidx.annotation.StringRes
|
import androidx.annotation.StringRes
|
||||||
import androidx.fragment.app.Fragment
|
import androidx.fragment.app.Fragment
|
||||||
import com.keylesspalace.tusky.components.conversation.ConversationsFragment
|
import com.keylesspalace.tusky.components.conversation.ConversationsFragment
|
||||||
|
import com.keylesspalace.tusky.components.notifications.NotificationsFragment
|
||||||
import com.keylesspalace.tusky.components.timeline.TimelineFragment
|
import com.keylesspalace.tusky.components.timeline.TimelineFragment
|
||||||
import com.keylesspalace.tusky.components.timeline.viewmodel.TimelineViewModel
|
import com.keylesspalace.tusky.components.timeline.viewmodel.TimelineViewModel
|
||||||
import com.keylesspalace.tusky.components.trending.TrendingTagsFragment
|
import com.keylesspalace.tusky.components.trending.TrendingTagsFragment
|
||||||
import com.keylesspalace.tusky.fragment.NotificationsFragment
|
|
||||||
import java.util.Objects
|
import java.util.Objects
|
||||||
|
|
||||||
/** this would be a good case for a sealed class, but that does not work nice with Room */
|
/** this would be a good case for a sealed class, but that does not work nice with Room */
|
||||||
|
|
|
@ -22,7 +22,7 @@ import androidx.work.Constraints
|
||||||
import androidx.work.ExistingPeriodicWorkPolicy
|
import androidx.work.ExistingPeriodicWorkPolicy
|
||||||
import androidx.work.PeriodicWorkRequestBuilder
|
import androidx.work.PeriodicWorkRequestBuilder
|
||||||
import androidx.work.WorkManager
|
import androidx.work.WorkManager
|
||||||
import com.keylesspalace.tusky.components.notifications.NotificationHelper
|
import com.keylesspalace.tusky.components.systemnotifications.NotificationHelper
|
||||||
import com.keylesspalace.tusky.di.AppInjector
|
import com.keylesspalace.tusky.di.AppInjector
|
||||||
import com.keylesspalace.tusky.settings.AppTheme
|
import com.keylesspalace.tusky.settings.AppTheme
|
||||||
import com.keylesspalace.tusky.settings.NEW_INSTALL_SCHEMA_VERSION
|
import com.keylesspalace.tusky.settings.NEW_INSTALL_SCHEMA_VERSION
|
||||||
|
@ -127,12 +127,6 @@ class TuskyApplication : Application(), HasAndroidInjector {
|
||||||
editor.remove(PrefKeys.MEDIA_PREVIEW_ENABLED)
|
editor.remove(PrefKeys.MEDIA_PREVIEW_ENABLED)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (oldVersion < 2023072401) {
|
|
||||||
// The notifications filter / clear options are shown on a menu, not a separate bar,
|
|
||||||
// the preference to display them is not needed.
|
|
||||||
editor.remove(PrefKeys.Deprecated.SHOW_NOTIFICATIONS_FILTER)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (oldVersion != NEW_INSTALL_SCHEMA_VERSION && oldVersion < 2023082301) {
|
if (oldVersion != NEW_INSTALL_SCHEMA_VERSION && oldVersion < 2023082301) {
|
||||||
// Default value for appTheme is now THEME_SYSTEM. If the user is upgrading and
|
// Default value for appTheme is now THEME_SYSTEM. If the user is upgrading and
|
||||||
// didn't have an explicit preference set use the previous default, so the
|
// didn't have an explicit preference set use the previous default, so the
|
||||||
|
|
|
@ -23,7 +23,7 @@ import android.widget.ArrayAdapter
|
||||||
import androidx.preference.PreferenceManager
|
import androidx.preference.PreferenceManager
|
||||||
import com.keylesspalace.tusky.R
|
import com.keylesspalace.tusky.R
|
||||||
import com.keylesspalace.tusky.databinding.ItemAutocompleteAccountBinding
|
import com.keylesspalace.tusky.databinding.ItemAutocompleteAccountBinding
|
||||||
import com.keylesspalace.tusky.db.AccountEntity
|
import com.keylesspalace.tusky.db.entity.AccountEntity
|
||||||
import com.keylesspalace.tusky.settings.PrefKeys
|
import com.keylesspalace.tusky.settings.PrefKeys
|
||||||
import com.keylesspalace.tusky.util.emojify
|
import com.keylesspalace.tusky.util.emojify
|
||||||
import com.keylesspalace.tusky.util.loadAvatar
|
import com.keylesspalace.tusky.util.loadAvatar
|
||||||
|
|
|
@ -21,10 +21,12 @@ import android.text.Spanned
|
||||||
import android.text.style.StyleSpan
|
import android.text.style.StyleSpan
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
import com.keylesspalace.tusky.R
|
import com.keylesspalace.tusky.R
|
||||||
|
import com.keylesspalace.tusky.components.notifications.NotificationsViewHolder
|
||||||
import com.keylesspalace.tusky.databinding.ItemFollowRequestBinding
|
import com.keylesspalace.tusky.databinding.ItemFollowRequestBinding
|
||||||
import com.keylesspalace.tusky.entity.TimelineAccount
|
import com.keylesspalace.tusky.entity.TimelineAccount
|
||||||
import com.keylesspalace.tusky.interfaces.AccountActionListener
|
import com.keylesspalace.tusky.interfaces.AccountActionListener
|
||||||
import com.keylesspalace.tusky.interfaces.LinkListener
|
import com.keylesspalace.tusky.interfaces.LinkListener
|
||||||
|
import com.keylesspalace.tusky.util.StatusDisplayOptions
|
||||||
import com.keylesspalace.tusky.util.emojify
|
import com.keylesspalace.tusky.util.emojify
|
||||||
import com.keylesspalace.tusky.util.hide
|
import com.keylesspalace.tusky.util.hide
|
||||||
import com.keylesspalace.tusky.util.loadAvatar
|
import com.keylesspalace.tusky.util.loadAvatar
|
||||||
|
@ -33,12 +35,28 @@ import com.keylesspalace.tusky.util.setClickableText
|
||||||
import com.keylesspalace.tusky.util.show
|
import com.keylesspalace.tusky.util.show
|
||||||
import com.keylesspalace.tusky.util.unicodeWrap
|
import com.keylesspalace.tusky.util.unicodeWrap
|
||||||
import com.keylesspalace.tusky.util.visible
|
import com.keylesspalace.tusky.util.visible
|
||||||
|
import com.keylesspalace.tusky.viewdata.NotificationViewData
|
||||||
|
|
||||||
class FollowRequestViewHolder(
|
class FollowRequestViewHolder(
|
||||||
private val binding: ItemFollowRequestBinding,
|
private val binding: ItemFollowRequestBinding,
|
||||||
|
private val accountListener: AccountActionListener,
|
||||||
private val linkListener: LinkListener,
|
private val linkListener: LinkListener,
|
||||||
private val showHeader: Boolean
|
private val showHeader: Boolean
|
||||||
) : RecyclerView.ViewHolder(binding.root) {
|
) : RecyclerView.ViewHolder(binding.root), NotificationsViewHolder {
|
||||||
|
|
||||||
|
override fun bind(
|
||||||
|
viewData: NotificationViewData.Concrete,
|
||||||
|
payloads: List<*>,
|
||||||
|
statusDisplayOptions: StatusDisplayOptions
|
||||||
|
) {
|
||||||
|
setupWithAccount(
|
||||||
|
viewData.account,
|
||||||
|
statusDisplayOptions.animateAvatars,
|
||||||
|
statusDisplayOptions.animateEmojis,
|
||||||
|
statusDisplayOptions.showBotOverlay
|
||||||
|
)
|
||||||
|
setupActionListener(accountListener, viewData.account.id)
|
||||||
|
}
|
||||||
|
|
||||||
fun setupWithAccount(
|
fun setupWithAccount(
|
||||||
account: TimelineAccount,
|
account: TimelineAccount,
|
||||||
|
|
|
@ -1,708 +0,0 @@
|
||||||
/* 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 android.content.Context;
|
|
||||||
import android.graphics.Color;
|
|
||||||
import android.graphics.PorterDuff;
|
|
||||||
import android.graphics.Typeface;
|
|
||||||
import android.graphics.drawable.Drawable;
|
|
||||||
import android.text.InputFilter;
|
|
||||||
import android.text.SpannableStringBuilder;
|
|
||||||
import android.text.Spanned;
|
|
||||||
import android.text.TextUtils;
|
|
||||||
import android.text.style.StyleSpan;
|
|
||||||
import android.view.LayoutInflater;
|
|
||||||
import android.view.View;
|
|
||||||
import android.view.ViewGroup;
|
|
||||||
import android.widget.Button;
|
|
||||||
import android.widget.ImageView;
|
|
||||||
import android.widget.TextView;
|
|
||||||
|
|
||||||
import androidx.annotation.ColorRes;
|
|
||||||
import androidx.annotation.DrawableRes;
|
|
||||||
import androidx.annotation.NonNull;
|
|
||||||
import androidx.annotation.Nullable;
|
|
||||||
import androidx.core.content.ContextCompat;
|
|
||||||
import androidx.recyclerview.widget.RecyclerView;
|
|
||||||
|
|
||||||
import com.bumptech.glide.Glide;
|
|
||||||
import com.keylesspalace.tusky.R;
|
|
||||||
import com.keylesspalace.tusky.databinding.ItemFollowRequestBinding;
|
|
||||||
import com.keylesspalace.tusky.databinding.ItemReportNotificationBinding;
|
|
||||||
import com.keylesspalace.tusky.entity.Emoji;
|
|
||||||
import com.keylesspalace.tusky.entity.Notification;
|
|
||||||
import com.keylesspalace.tusky.entity.Status;
|
|
||||||
import com.keylesspalace.tusky.entity.TimelineAccount;
|
|
||||||
import com.keylesspalace.tusky.interfaces.AccountActionListener;
|
|
||||||
import com.keylesspalace.tusky.interfaces.LinkListener;
|
|
||||||
import com.keylesspalace.tusky.interfaces.StatusActionListener;
|
|
||||||
import com.keylesspalace.tusky.util.AbsoluteTimeFormatter;
|
|
||||||
import com.keylesspalace.tusky.util.CardViewMode;
|
|
||||||
import com.keylesspalace.tusky.util.CustomEmojiHelper;
|
|
||||||
import com.keylesspalace.tusky.util.ImageLoadingHelper;
|
|
||||||
import com.keylesspalace.tusky.util.LinkHelper;
|
|
||||||
import com.keylesspalace.tusky.util.SmartLengthInputFilter;
|
|
||||||
import com.keylesspalace.tusky.util.StatusDisplayOptions;
|
|
||||||
import com.keylesspalace.tusky.util.StringUtils;
|
|
||||||
import com.keylesspalace.tusky.util.TimestampUtils;
|
|
||||||
import com.keylesspalace.tusky.viewdata.NotificationViewData;
|
|
||||||
import com.keylesspalace.tusky.viewdata.StatusViewData;
|
|
||||||
|
|
||||||
import java.util.Date;
|
|
||||||
import java.util.List;
|
|
||||||
|
|
||||||
import at.connyduck.sparkbutton.helpers.Utils;
|
|
||||||
|
|
||||||
public class NotificationsAdapter extends RecyclerView.Adapter<RecyclerView.ViewHolder> implements LinkListener{
|
|
||||||
|
|
||||||
public interface AdapterDataSource<T> {
|
|
||||||
int getItemCount();
|
|
||||||
|
|
||||||
T getItemAt(int pos);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
private static final int VIEW_TYPE_STATUS = 0;
|
|
||||||
private static final int VIEW_TYPE_STATUS_NOTIFICATION = 1;
|
|
||||||
private static final int VIEW_TYPE_FOLLOW = 2;
|
|
||||||
private static final int VIEW_TYPE_FOLLOW_REQUEST = 3;
|
|
||||||
private static final int VIEW_TYPE_PLACEHOLDER = 4;
|
|
||||||
private static final int VIEW_TYPE_REPORT = 5;
|
|
||||||
private static final int VIEW_TYPE_UNKNOWN = 6;
|
|
||||||
|
|
||||||
private static final InputFilter[] COLLAPSE_INPUT_FILTER = new InputFilter[]{SmartLengthInputFilter.INSTANCE};
|
|
||||||
private static final InputFilter[] NO_INPUT_FILTER = new InputFilter[0];
|
|
||||||
|
|
||||||
private final String accountId;
|
|
||||||
private StatusDisplayOptions statusDisplayOptions;
|
|
||||||
private final StatusActionListener statusListener;
|
|
||||||
private final NotificationActionListener notificationActionListener;
|
|
||||||
private final AccountActionListener accountActionListener;
|
|
||||||
private final AdapterDataSource<NotificationViewData> dataSource;
|
|
||||||
private final AbsoluteTimeFormatter absoluteTimeFormatter = new AbsoluteTimeFormatter();
|
|
||||||
|
|
||||||
public NotificationsAdapter(String accountId,
|
|
||||||
AdapterDataSource<NotificationViewData> dataSource,
|
|
||||||
StatusDisplayOptions statusDisplayOptions,
|
|
||||||
StatusActionListener statusListener,
|
|
||||||
NotificationActionListener notificationActionListener,
|
|
||||||
AccountActionListener accountActionListener) {
|
|
||||||
|
|
||||||
this.accountId = accountId;
|
|
||||||
this.dataSource = dataSource;
|
|
||||||
this.statusDisplayOptions = statusDisplayOptions;
|
|
||||||
this.statusListener = statusListener;
|
|
||||||
this.notificationActionListener = notificationActionListener;
|
|
||||||
this.accountActionListener = accountActionListener;
|
|
||||||
}
|
|
||||||
|
|
||||||
@NonNull
|
|
||||||
@Override
|
|
||||||
public RecyclerView.ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
|
|
||||||
LayoutInflater inflater = LayoutInflater.from(parent.getContext());
|
|
||||||
switch (viewType) {
|
|
||||||
case VIEW_TYPE_STATUS: {
|
|
||||||
View view = inflater
|
|
||||||
.inflate(R.layout.item_status, parent, false);
|
|
||||||
return new StatusViewHolder(view);
|
|
||||||
}
|
|
||||||
case VIEW_TYPE_STATUS_NOTIFICATION: {
|
|
||||||
View view = inflater
|
|
||||||
.inflate(R.layout.item_status_notification, parent, false);
|
|
||||||
return new StatusNotificationViewHolder(view, statusDisplayOptions, absoluteTimeFormatter);
|
|
||||||
}
|
|
||||||
case VIEW_TYPE_FOLLOW: {
|
|
||||||
View view = inflater
|
|
||||||
.inflate(R.layout.item_follow, parent, false);
|
|
||||||
return new FollowViewHolder(view, statusDisplayOptions);
|
|
||||||
}
|
|
||||||
case VIEW_TYPE_FOLLOW_REQUEST: {
|
|
||||||
ItemFollowRequestBinding binding = ItemFollowRequestBinding.inflate(inflater, parent, false);
|
|
||||||
return new FollowRequestViewHolder(binding, this, true);
|
|
||||||
}
|
|
||||||
case VIEW_TYPE_PLACEHOLDER: {
|
|
||||||
View view = inflater
|
|
||||||
.inflate(R.layout.item_status_placeholder, parent, false);
|
|
||||||
return new PlaceholderViewHolder(view);
|
|
||||||
}
|
|
||||||
case VIEW_TYPE_REPORT: {
|
|
||||||
ItemReportNotificationBinding binding = ItemReportNotificationBinding.inflate(inflater, parent, false);
|
|
||||||
return new ReportNotificationViewHolder(binding);
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
case VIEW_TYPE_UNKNOWN: {
|
|
||||||
View view = new View(parent.getContext());
|
|
||||||
view.setLayoutParams(
|
|
||||||
new ViewGroup.LayoutParams(
|
|
||||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
|
||||||
Utils.dpToPx(parent.getContext(), 24)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
return new RecyclerView.ViewHolder(view) {
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onBindViewHolder(@NonNull RecyclerView.ViewHolder viewHolder, int position) {
|
|
||||||
bindViewHolder(viewHolder, position, null);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onBindViewHolder(@NonNull RecyclerView.ViewHolder viewHolder, int position, @NonNull List<Object> payloads) {
|
|
||||||
bindViewHolder(viewHolder, position, payloads);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void bindViewHolder(@NonNull RecyclerView.ViewHolder viewHolder, int position, @Nullable List<Object> payloads) {
|
|
||||||
Object payloadForHolder = payloads != null && !payloads.isEmpty() ? payloads.get(0) : null;
|
|
||||||
if (position < this.dataSource.getItemCount()) {
|
|
||||||
NotificationViewData notification = dataSource.getItemAt(position);
|
|
||||||
if (notification instanceof NotificationViewData.Placeholder) {
|
|
||||||
if (payloadForHolder == null) {
|
|
||||||
NotificationViewData.Placeholder placeholder = ((NotificationViewData.Placeholder) notification);
|
|
||||||
PlaceholderViewHolder holder = (PlaceholderViewHolder) viewHolder;
|
|
||||||
holder.setup(statusListener, placeholder.isLoading());
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
NotificationViewData.Concrete concreteNotification =
|
|
||||||
(NotificationViewData.Concrete) notification;
|
|
||||||
switch (viewHolder.getItemViewType()) {
|
|
||||||
case VIEW_TYPE_STATUS: {
|
|
||||||
StatusViewHolder holder = (StatusViewHolder) viewHolder;
|
|
||||||
StatusViewData.Concrete status = concreteNotification.getStatusViewData();
|
|
||||||
if (status == null) {
|
|
||||||
/* in some very rare cases servers sends null status even though they should not,
|
|
||||||
* we have to handle it somehow */
|
|
||||||
holder.showStatusContent(false);
|
|
||||||
} else {
|
|
||||||
if (payloads == null) {
|
|
||||||
holder.showStatusContent(true);
|
|
||||||
}
|
|
||||||
holder.setupWithStatus(status, statusListener, statusDisplayOptions, payloadForHolder);
|
|
||||||
}
|
|
||||||
if (concreteNotification.getType() == Notification.Type.POLL) {
|
|
||||||
holder.setPollInfo(accountId.equals(concreteNotification.getAccount().getId()));
|
|
||||||
} else {
|
|
||||||
holder.hideStatusInfo();
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case VIEW_TYPE_STATUS_NOTIFICATION: {
|
|
||||||
StatusNotificationViewHolder holder = (StatusNotificationViewHolder) viewHolder;
|
|
||||||
StatusViewData.Concrete statusViewData = concreteNotification.getStatusViewData();
|
|
||||||
if (payloadForHolder == null) {
|
|
||||||
if (statusViewData == null) {
|
|
||||||
/* in some very rare cases servers sends null status even though they should not,
|
|
||||||
* we have to handle it somehow */
|
|
||||||
holder.showNotificationContent(false);
|
|
||||||
} else {
|
|
||||||
holder.showNotificationContent(true);
|
|
||||||
|
|
||||||
Status status = statusViewData.getActionable();
|
|
||||||
holder.setDisplayName(status.getAccount().getDisplayName(), status.getAccount().getEmojis());
|
|
||||||
holder.setUsername(status.getAccount().getUsername());
|
|
||||||
holder.setCreatedAt(status.getCreatedAt());
|
|
||||||
|
|
||||||
if (concreteNotification.getType() == Notification.Type.STATUS ||
|
|
||||||
concreteNotification.getType() == Notification.Type.UPDATE) {
|
|
||||||
holder.setAvatar(status.getAccount().getAvatar(), status.getAccount().getBot());
|
|
||||||
} else {
|
|
||||||
holder.setAvatars(status.getAccount().getAvatar(),
|
|
||||||
concreteNotification.getAccount().getAvatar());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
holder.setMessage(concreteNotification, statusListener);
|
|
||||||
holder.setupButtons(notificationActionListener,
|
|
||||||
concreteNotification.getAccount().getId(),
|
|
||||||
concreteNotification.getId());
|
|
||||||
} else {
|
|
||||||
if (payloadForHolder instanceof List)
|
|
||||||
for (Object item : (List<?>) payloadForHolder) {
|
|
||||||
if (StatusBaseViewHolder.Key.KEY_CREATED.equals(item) && statusViewData != null) {
|
|
||||||
holder.setCreatedAt(statusViewData.getStatus().getActionableStatus().getCreatedAt());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case VIEW_TYPE_FOLLOW: {
|
|
||||||
if (payloadForHolder == null) {
|
|
||||||
FollowViewHolder holder = (FollowViewHolder) viewHolder;
|
|
||||||
holder.setMessage(concreteNotification.getAccount(), concreteNotification.getType() == Notification.Type.SIGN_UP);
|
|
||||||
holder.setupButtons(notificationActionListener, concreteNotification.getAccount().getId());
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case VIEW_TYPE_FOLLOW_REQUEST: {
|
|
||||||
if (payloadForHolder == null) {
|
|
||||||
FollowRequestViewHolder holder = (FollowRequestViewHolder) viewHolder;
|
|
||||||
holder.setupWithAccount(concreteNotification.getAccount(), statusDisplayOptions.animateAvatars(), statusDisplayOptions.animateEmojis(), statusDisplayOptions.showBotOverlay());
|
|
||||||
holder.setupActionListener(accountActionListener, concreteNotification.getAccount().getId());
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case VIEW_TYPE_REPORT: {
|
|
||||||
if (payloadForHolder == null) {
|
|
||||||
ReportNotificationViewHolder holder = (ReportNotificationViewHolder) viewHolder;
|
|
||||||
holder.setupWithReport(concreteNotification.getAccount(), concreteNotification.getReport(), statusDisplayOptions.animateAvatars(), statusDisplayOptions.animateEmojis());
|
|
||||||
holder.setupActionListener(notificationActionListener, concreteNotification.getReport().getTargetAccount().getId(), concreteNotification.getAccount().getId(), concreteNotification.getReport().getId());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public int getItemCount() {
|
|
||||||
return dataSource.getItemCount();
|
|
||||||
}
|
|
||||||
|
|
||||||
public void setMediaPreviewEnabled(boolean mediaPreviewEnabled) {
|
|
||||||
this.statusDisplayOptions = statusDisplayOptions.copy(
|
|
||||||
statusDisplayOptions.animateAvatars(),
|
|
||||||
mediaPreviewEnabled,
|
|
||||||
statusDisplayOptions.useAbsoluteTime(),
|
|
||||||
statusDisplayOptions.showBotOverlay(),
|
|
||||||
statusDisplayOptions.useBlurhash(),
|
|
||||||
CardViewMode.NONE,
|
|
||||||
statusDisplayOptions.confirmReblogs(),
|
|
||||||
statusDisplayOptions.confirmFavourites(),
|
|
||||||
statusDisplayOptions.hideStats(),
|
|
||||||
statusDisplayOptions.animateEmojis(),
|
|
||||||
statusDisplayOptions.showStatsInline(),
|
|
||||||
statusDisplayOptions.showSensitiveMedia(),
|
|
||||||
statusDisplayOptions.openSpoiler()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
public boolean isMediaPreviewEnabled() {
|
|
||||||
return this.statusDisplayOptions.mediaPreviewEnabled();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public int getItemViewType(int position) {
|
|
||||||
NotificationViewData notification = dataSource.getItemAt(position);
|
|
||||||
if (notification instanceof NotificationViewData.Concrete) {
|
|
||||||
NotificationViewData.Concrete concrete = ((NotificationViewData.Concrete) notification);
|
|
||||||
switch (concrete.getType()) {
|
|
||||||
case MENTION:
|
|
||||||
case POLL: {
|
|
||||||
return VIEW_TYPE_STATUS;
|
|
||||||
}
|
|
||||||
case STATUS:
|
|
||||||
case FAVOURITE:
|
|
||||||
case REBLOG:
|
|
||||||
case UPDATE: {
|
|
||||||
return VIEW_TYPE_STATUS_NOTIFICATION;
|
|
||||||
}
|
|
||||||
case FOLLOW:
|
|
||||||
case SIGN_UP: {
|
|
||||||
return VIEW_TYPE_FOLLOW;
|
|
||||||
}
|
|
||||||
case FOLLOW_REQUEST: {
|
|
||||||
return VIEW_TYPE_FOLLOW_REQUEST;
|
|
||||||
}
|
|
||||||
case REPORT: {
|
|
||||||
return VIEW_TYPE_REPORT;
|
|
||||||
}
|
|
||||||
default: {
|
|
||||||
return VIEW_TYPE_UNKNOWN;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if (notification instanceof NotificationViewData.Placeholder) {
|
|
||||||
return VIEW_TYPE_PLACEHOLDER;
|
|
||||||
} else {
|
|
||||||
throw new AssertionError("Unknown notification type");
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
public interface NotificationActionListener {
|
|
||||||
void onViewAccount(String id);
|
|
||||||
|
|
||||||
void onViewStatusForNotificationId(String notificationId);
|
|
||||||
|
|
||||||
void onViewReport(String reportId);
|
|
||||||
|
|
||||||
void onExpandedChange(boolean expanded, int position);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Called when the status {@link android.widget.ToggleButton} responsible for collapsing long
|
|
||||||
* status content is interacted with.
|
|
||||||
*
|
|
||||||
* @param isCollapsed Whether the status content is shown in a collapsed state or fully.
|
|
||||||
* @param position The position of the status in the list.
|
|
||||||
*/
|
|
||||||
void onNotificationContentCollapsedChange(boolean isCollapsed, int position);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static class FollowViewHolder extends RecyclerView.ViewHolder {
|
|
||||||
private final TextView message;
|
|
||||||
private final TextView usernameView;
|
|
||||||
private final TextView displayNameView;
|
|
||||||
private final ImageView avatar;
|
|
||||||
private final StatusDisplayOptions statusDisplayOptions;
|
|
||||||
|
|
||||||
FollowViewHolder(View itemView, StatusDisplayOptions statusDisplayOptions) {
|
|
||||||
super(itemView);
|
|
||||||
message = itemView.findViewById(R.id.notification_text);
|
|
||||||
usernameView = itemView.findViewById(R.id.notification_username);
|
|
||||||
displayNameView = itemView.findViewById(R.id.notification_display_name);
|
|
||||||
avatar = itemView.findViewById(R.id.notification_avatar);
|
|
||||||
this.statusDisplayOptions = statusDisplayOptions;
|
|
||||||
}
|
|
||||||
|
|
||||||
void setMessage(TimelineAccount account, Boolean isSignUp) {
|
|
||||||
Context context = message.getContext();
|
|
||||||
|
|
||||||
String format = context.getString(isSignUp ? R.string.notification_sign_up_format : R.string.notification_follow_format);
|
|
||||||
String wrappedDisplayName = StringUtils.unicodeWrap(account.getName());
|
|
||||||
String wholeMessage = String.format(format, wrappedDisplayName);
|
|
||||||
CharSequence emojifiedMessage = CustomEmojiHelper.emojify(
|
|
||||||
wholeMessage, account.getEmojis(), message, statusDisplayOptions.animateEmojis()
|
|
||||||
);
|
|
||||||
message.setText(emojifiedMessage);
|
|
||||||
|
|
||||||
String username = context.getString(R.string.post_username_format, account.getUsername());
|
|
||||||
usernameView.setText(username);
|
|
||||||
|
|
||||||
CharSequence emojifiedDisplayName = CustomEmojiHelper.emojify(
|
|
||||||
wrappedDisplayName, account.getEmojis(), usernameView, statusDisplayOptions.animateEmojis()
|
|
||||||
);
|
|
||||||
|
|
||||||
displayNameView.setText(emojifiedDisplayName);
|
|
||||||
|
|
||||||
int avatarRadius = avatar.getContext().getResources()
|
|
||||||
.getDimensionPixelSize(R.dimen.avatar_radius_42dp);
|
|
||||||
|
|
||||||
ImageLoadingHelper.loadAvatar(account.getAvatar(), avatar, avatarRadius,
|
|
||||||
statusDisplayOptions.animateAvatars(), null);
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
void setupButtons(final NotificationActionListener listener, final String accountId) {
|
|
||||||
itemView.setOnClickListener(v -> listener.onViewAccount(accountId));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static class StatusNotificationViewHolder extends RecyclerView.ViewHolder
|
|
||||||
implements View.OnClickListener {
|
|
||||||
|
|
||||||
private final View container;
|
|
||||||
private final TextView message;
|
|
||||||
// private final View statusNameBar;
|
|
||||||
private final TextView displayName;
|
|
||||||
private final TextView username;
|
|
||||||
private final TextView timestampInfo;
|
|
||||||
private final TextView statusContent;
|
|
||||||
private final ImageView statusAvatar;
|
|
||||||
private final ImageView notificationAvatar;
|
|
||||||
private final TextView contentWarningDescriptionTextView;
|
|
||||||
private final Button contentWarningButton;
|
|
||||||
private final Button contentCollapseButton; // TODO: This code SHOULD be based on StatusBaseViewHolder
|
|
||||||
private final StatusDisplayOptions statusDisplayOptions;
|
|
||||||
private final AbsoluteTimeFormatter absoluteTimeFormatter;
|
|
||||||
|
|
||||||
private String accountId;
|
|
||||||
private String notificationId;
|
|
||||||
private NotificationActionListener notificationActionListener;
|
|
||||||
private StatusViewData.Concrete statusViewData;
|
|
||||||
|
|
||||||
private final int avatarRadius48dp;
|
|
||||||
private final int avatarRadius36dp;
|
|
||||||
private final int avatarRadius24dp;
|
|
||||||
|
|
||||||
StatusNotificationViewHolder(
|
|
||||||
View itemView,
|
|
||||||
StatusDisplayOptions statusDisplayOptions,
|
|
||||||
AbsoluteTimeFormatter absoluteTimeFormatter
|
|
||||||
) {
|
|
||||||
super(itemView);
|
|
||||||
message = itemView.findViewById(R.id.notification_top_text);
|
|
||||||
// statusNameBar = itemView.findViewById(R.id.status_name_bar);
|
|
||||||
displayName = itemView.findViewById(R.id.status_display_name);
|
|
||||||
username = itemView.findViewById(R.id.status_username);
|
|
||||||
timestampInfo = itemView.findViewById(R.id.status_meta_info);
|
|
||||||
statusContent = itemView.findViewById(R.id.notification_content);
|
|
||||||
statusAvatar = itemView.findViewById(R.id.notification_status_avatar);
|
|
||||||
notificationAvatar = itemView.findViewById(R.id.notification_notification_avatar);
|
|
||||||
contentWarningDescriptionTextView = itemView.findViewById(R.id.notification_content_warning_description);
|
|
||||||
contentWarningButton = itemView.findViewById(R.id.notification_content_warning_button);
|
|
||||||
contentCollapseButton = itemView.findViewById(R.id.button_toggle_notification_content);
|
|
||||||
|
|
||||||
container = itemView.findViewById(R.id.notification_container);
|
|
||||||
|
|
||||||
this.statusDisplayOptions = statusDisplayOptions;
|
|
||||||
this.absoluteTimeFormatter = absoluteTimeFormatter;
|
|
||||||
|
|
||||||
int darkerFilter = Color.rgb(123, 123, 123);
|
|
||||||
statusAvatar.setColorFilter(darkerFilter, PorterDuff.Mode.MULTIPLY);
|
|
||||||
notificationAvatar.setColorFilter(darkerFilter, PorterDuff.Mode.MULTIPLY);
|
|
||||||
|
|
||||||
itemView.setOnClickListener(this);
|
|
||||||
message.setOnClickListener(this);
|
|
||||||
statusContent.setOnClickListener(this);
|
|
||||||
|
|
||||||
this.avatarRadius48dp = itemView.getContext().getResources().getDimensionPixelSize(R.dimen.avatar_radius_48dp);
|
|
||||||
this.avatarRadius36dp = itemView.getContext().getResources().getDimensionPixelSize(R.dimen.avatar_radius_36dp);
|
|
||||||
this.avatarRadius24dp = itemView.getContext().getResources().getDimensionPixelSize(R.dimen.avatar_radius_24dp);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void showNotificationContent(boolean show) {
|
|
||||||
// statusNameBar.setVisibility(show ? View.VISIBLE : View.GONE);
|
|
||||||
contentWarningDescriptionTextView.setVisibility(show ? View.VISIBLE : View.GONE);
|
|
||||||
contentWarningButton.setVisibility(show ? View.VISIBLE : View.GONE);
|
|
||||||
statusContent.setVisibility(show ? View.VISIBLE : View.GONE);
|
|
||||||
statusAvatar.setVisibility(show ? View.VISIBLE : View.GONE);
|
|
||||||
notificationAvatar.setVisibility(show ? View.VISIBLE : View.GONE);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void setDisplayName(String name, List<Emoji> emojis) {
|
|
||||||
CharSequence emojifiedName = CustomEmojiHelper.emojify(name, emojis, displayName, statusDisplayOptions.animateEmojis());
|
|
||||||
displayName.setText(emojifiedName);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void setUsername(String name) {
|
|
||||||
Context context = username.getContext();
|
|
||||||
String format = context.getString(R.string.post_username_format);
|
|
||||||
String usernameText = String.format(format, name);
|
|
||||||
username.setText(usernameText);
|
|
||||||
}
|
|
||||||
|
|
||||||
protected void setCreatedAt(@Nullable Date createdAt) {
|
|
||||||
if (statusDisplayOptions.useAbsoluteTime()) {
|
|
||||||
timestampInfo.setText(absoluteTimeFormatter.format(createdAt, true));
|
|
||||||
} else {
|
|
||||||
// This is the visible timestampInfo.
|
|
||||||
String readout;
|
|
||||||
/* This one is for screen-readers. Frequently, they would mispronounce timestamps like "17m"
|
|
||||||
* as 17 meters instead of minutes. */
|
|
||||||
CharSequence readoutAloud;
|
|
||||||
if (createdAt != null) {
|
|
||||||
long then = createdAt.getTime();
|
|
||||||
long now = new Date().getTime();
|
|
||||||
readout = TimestampUtils.getRelativeTimeSpanString(timestampInfo.getContext(), then, now);
|
|
||||||
readoutAloud = android.text.format.DateUtils.getRelativeTimeSpanString(then, now,
|
|
||||||
android.text.format.DateUtils.SECOND_IN_MILLIS,
|
|
||||||
android.text.format.DateUtils.FORMAT_ABBREV_RELATIVE);
|
|
||||||
} else {
|
|
||||||
// unknown minutes~
|
|
||||||
readout = "?m";
|
|
||||||
readoutAloud = "? minutes";
|
|
||||||
}
|
|
||||||
timestampInfo.setText(readout);
|
|
||||||
timestampInfo.setContentDescription(readoutAloud);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Drawable getIconWithColor(Context context, @DrawableRes int drawable, @ColorRes int color) {
|
|
||||||
Drawable icon = ContextCompat.getDrawable(context, drawable);
|
|
||||||
if (icon != null) {
|
|
||||||
icon.setColorFilter(context.getColor(color), PorterDuff.Mode.SRC_ATOP);
|
|
||||||
}
|
|
||||||
return icon;
|
|
||||||
}
|
|
||||||
|
|
||||||
void setMessage(NotificationViewData.Concrete notificationViewData, LinkListener listener) {
|
|
||||||
this.statusViewData = notificationViewData.getStatusViewData();
|
|
||||||
|
|
||||||
String displayName = StringUtils.unicodeWrap(notificationViewData.getAccount().getName());
|
|
||||||
Notification.Type type = notificationViewData.getType();
|
|
||||||
|
|
||||||
Context context = message.getContext();
|
|
||||||
String format;
|
|
||||||
Drawable icon;
|
|
||||||
switch (type) {
|
|
||||||
default:
|
|
||||||
case FAVOURITE: {
|
|
||||||
icon = getIconWithColor(context, R.drawable.ic_star_24dp, R.color.tusky_orange);
|
|
||||||
format = context.getString(R.string.notification_favourite_format);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case REBLOG: {
|
|
||||||
icon = getIconWithColor(context, R.drawable.ic_repeat_24dp, R.color.tusky_blue);
|
|
||||||
format = context.getString(R.string.notification_reblog_format);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case STATUS: {
|
|
||||||
icon = getIconWithColor(context, R.drawable.ic_home_24dp, R.color.tusky_blue);
|
|
||||||
format = context.getString(R.string.notification_subscription_format);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case UPDATE: {
|
|
||||||
icon = getIconWithColor(context, R.drawable.ic_edit_24dp, R.color.tusky_blue);
|
|
||||||
format = context.getString(R.string.notification_update_format);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
message.setCompoundDrawablesWithIntrinsicBounds(icon, null, null, null);
|
|
||||||
String wholeMessage = String.format(format, displayName);
|
|
||||||
final SpannableStringBuilder str = new SpannableStringBuilder(wholeMessage);
|
|
||||||
int displayNameIndex = format.indexOf("%1$s");
|
|
||||||
str.setSpan(
|
|
||||||
new StyleSpan(Typeface.BOLD),
|
|
||||||
displayNameIndex,
|
|
||||||
displayNameIndex + displayName.length(),
|
|
||||||
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
|
|
||||||
);
|
|
||||||
CharSequence emojifiedText = CustomEmojiHelper.emojify(
|
|
||||||
str, notificationViewData.getAccount().getEmojis(), message, statusDisplayOptions.animateEmojis()
|
|
||||||
);
|
|
||||||
message.setText(emojifiedText);
|
|
||||||
|
|
||||||
if (statusViewData != null) {
|
|
||||||
boolean hasSpoiler = !TextUtils.isEmpty(statusViewData.getStatus().getSpoilerText());
|
|
||||||
contentWarningDescriptionTextView.setVisibility(hasSpoiler ? View.VISIBLE : View.GONE);
|
|
||||||
contentWarningButton.setVisibility(hasSpoiler ? View.VISIBLE : View.GONE);
|
|
||||||
if (statusViewData.isExpanded()) {
|
|
||||||
contentWarningButton.setText(R.string.post_content_warning_show_less);
|
|
||||||
} else {
|
|
||||||
contentWarningButton.setText(R.string.post_content_warning_show_more);
|
|
||||||
}
|
|
||||||
|
|
||||||
contentWarningButton.setOnClickListener(view -> {
|
|
||||||
if (getBindingAdapterPosition() != RecyclerView.NO_POSITION) {
|
|
||||||
notificationActionListener.onExpandedChange(!statusViewData.isExpanded(), getBindingAdapterPosition());
|
|
||||||
}
|
|
||||||
statusContent.setVisibility(statusViewData.isExpanded() ? View.GONE : View.VISIBLE);
|
|
||||||
});
|
|
||||||
|
|
||||||
setupContentAndSpoiler(listener);
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
void setupButtons(final NotificationActionListener listener, final String accountId,
|
|
||||||
final String notificationId) {
|
|
||||||
this.notificationActionListener = listener;
|
|
||||||
this.accountId = accountId;
|
|
||||||
this.notificationId = notificationId;
|
|
||||||
}
|
|
||||||
|
|
||||||
void setAvatar(@Nullable String statusAvatarUrl, boolean isBot) {
|
|
||||||
statusAvatar.setPaddingRelative(0, 0, 0, 0);
|
|
||||||
|
|
||||||
ImageLoadingHelper.loadAvatar(statusAvatarUrl,
|
|
||||||
statusAvatar, avatarRadius48dp, statusDisplayOptions.animateAvatars(), null);
|
|
||||||
|
|
||||||
if (statusDisplayOptions.showBotOverlay() && isBot) {
|
|
||||||
notificationAvatar.setVisibility(View.VISIBLE);
|
|
||||||
Glide.with(notificationAvatar)
|
|
||||||
.load(R.drawable.bot_badge)
|
|
||||||
.into(notificationAvatar);
|
|
||||||
|
|
||||||
} else {
|
|
||||||
notificationAvatar.setVisibility(View.GONE);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void setAvatars(@Nullable String statusAvatarUrl, @Nullable String notificationAvatarUrl) {
|
|
||||||
int padding = Utils.dpToPx(statusAvatar.getContext(), 12);
|
|
||||||
statusAvatar.setPaddingRelative(0, 0, padding, padding);
|
|
||||||
|
|
||||||
ImageLoadingHelper.loadAvatar(statusAvatarUrl,
|
|
||||||
statusAvatar, avatarRadius36dp, statusDisplayOptions.animateAvatars(), null);
|
|
||||||
|
|
||||||
notificationAvatar.setVisibility(View.VISIBLE);
|
|
||||||
ImageLoadingHelper.loadAvatar(notificationAvatarUrl, notificationAvatar,
|
|
||||||
avatarRadius24dp, statusDisplayOptions.animateAvatars(), null);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onClick(View v) {
|
|
||||||
if (notificationActionListener == null)
|
|
||||||
return;
|
|
||||||
|
|
||||||
if (v == container || v == statusContent) {
|
|
||||||
notificationActionListener.onViewStatusForNotificationId(notificationId);
|
|
||||||
}
|
|
||||||
else if (v == message) {
|
|
||||||
notificationActionListener.onViewAccount(accountId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void setupContentAndSpoiler(final LinkListener listener) {
|
|
||||||
|
|
||||||
boolean shouldShowContentIfSpoiler = statusViewData.isExpanded();
|
|
||||||
boolean hasSpoiler = !TextUtils.isEmpty(statusViewData.getStatus(). getSpoilerText());
|
|
||||||
if (!shouldShowContentIfSpoiler && hasSpoiler) {
|
|
||||||
statusContent.setVisibility(View.GONE);
|
|
||||||
} else {
|
|
||||||
statusContent.setVisibility(View.VISIBLE);
|
|
||||||
}
|
|
||||||
|
|
||||||
Spanned content = statusViewData.getContent();
|
|
||||||
List<Emoji> emojis = statusViewData.getActionable().getEmojis();
|
|
||||||
|
|
||||||
if (statusViewData.isCollapsible() && (statusViewData.isExpanded() || !hasSpoiler)) {
|
|
||||||
contentCollapseButton.setOnClickListener(view -> {
|
|
||||||
int position = getBindingAdapterPosition();
|
|
||||||
if (position != RecyclerView.NO_POSITION && notificationActionListener != null) {
|
|
||||||
notificationActionListener.onNotificationContentCollapsedChange(!statusViewData.isCollapsed(), position);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
contentCollapseButton.setVisibility(View.VISIBLE);
|
|
||||||
if (statusViewData.isCollapsed()) {
|
|
||||||
contentCollapseButton.setText(R.string.post_content_warning_show_more);
|
|
||||||
statusContent.setFilters(COLLAPSE_INPUT_FILTER);
|
|
||||||
} else {
|
|
||||||
contentCollapseButton.setText(R.string.post_content_warning_show_less);
|
|
||||||
statusContent.setFilters(NO_INPUT_FILTER);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
contentCollapseButton.setVisibility(View.GONE);
|
|
||||||
statusContent.setFilters(NO_INPUT_FILTER);
|
|
||||||
}
|
|
||||||
|
|
||||||
CharSequence emojifiedText = CustomEmojiHelper.emojify(
|
|
||||||
content, emojis, statusContent, statusDisplayOptions.animateEmojis()
|
|
||||||
);
|
|
||||||
LinkHelper.setClickableText(statusContent, emojifiedText, statusViewData.getActionable().getMentions(), statusViewData.getActionable().getTags(), listener);
|
|
||||||
|
|
||||||
CharSequence emojifiedContentWarning = CustomEmojiHelper.emojify(
|
|
||||||
statusViewData.getStatus().getSpoilerText(),
|
|
||||||
statusViewData.getActionable().getEmojis(),
|
|
||||||
contentWarningDescriptionTextView,
|
|
||||||
statusDisplayOptions.animateEmojis()
|
|
||||||
);
|
|
||||||
contentWarningDescriptionTextView.setText(emojifiedContentWarning);
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onViewTag(@NonNull String tag) {
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onViewAccount(@NonNull String id) {
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onViewUrl(@NonNull String url) {
|
|
||||||
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -14,54 +14,34 @@
|
||||||
* see <http://www.gnu.org/licenses>. */
|
* see <http://www.gnu.org/licenses>. */
|
||||||
package com.keylesspalace.tusky.adapter
|
package com.keylesspalace.tusky.adapter
|
||||||
|
|
||||||
import android.view.View
|
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
import com.google.android.material.button.MaterialButton
|
import com.keylesspalace.tusky.databinding.ItemStatusPlaceholderBinding
|
||||||
import com.google.android.material.progressindicator.CircularProgressIndicatorSpec
|
|
||||||
import com.google.android.material.progressindicator.IndeterminateDrawable
|
|
||||||
import com.keylesspalace.tusky.R
|
|
||||||
import com.keylesspalace.tusky.interfaces.StatusActionListener
|
import com.keylesspalace.tusky.interfaces.StatusActionListener
|
||||||
|
import com.keylesspalace.tusky.util.hide
|
||||||
|
import com.keylesspalace.tusky.util.show
|
||||||
|
import com.keylesspalace.tusky.util.visible
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Placeholder for different timelines.
|
* Placeholder for missing parts in timelines.
|
||||||
*
|
*
|
||||||
* Displays a "Load more" button for a particular status ID, or a
|
* Displays a "Load more" button to load the gap, or a
|
||||||
* circular progress wheel if the status' page is being loaded.
|
* circular progress bar if the missing page is being loaded.
|
||||||
*
|
|
||||||
* The user can only have one "Load more" operation in progress at
|
|
||||||
* a time (determined by the adapter), so the contents of the view
|
|
||||||
* and the enabled state is driven by that.
|
|
||||||
*/
|
*/
|
||||||
class PlaceholderViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
|
class PlaceholderViewHolder(
|
||||||
private val loadMoreButton: MaterialButton = itemView.findViewById(R.id.button_load_more)
|
private val binding: ItemStatusPlaceholderBinding,
|
||||||
private val drawable = IndeterminateDrawable.createCircularDrawable(
|
listener: StatusActionListener
|
||||||
itemView.context,
|
) : RecyclerView.ViewHolder(binding.root) {
|
||||||
CircularProgressIndicatorSpec(itemView.context, null)
|
|
||||||
)
|
|
||||||
|
|
||||||
fun setup(listener: StatusActionListener, loading: Boolean) {
|
init {
|
||||||
itemView.isEnabled = !loading
|
binding.loadMoreButton.setOnClickListener {
|
||||||
loadMoreButton.isEnabled = !loading
|
binding.loadMoreButton.hide()
|
||||||
|
binding.loadMoreProgressBar.show()
|
||||||
if (loading) {
|
|
||||||
loadMoreButton.text = ""
|
|
||||||
loadMoreButton.icon = drawable
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
loadMoreButton.text = itemView.context.getString(R.string.load_more_placeholder_text)
|
|
||||||
loadMoreButton.icon = null
|
|
||||||
|
|
||||||
// To allow the user to click anywhere in the layout to load more content set the click
|
|
||||||
// listener on the parent layout instead of loadMoreButton.
|
|
||||||
//
|
|
||||||
// See the comments in item_status_placeholder.xml for more details.
|
|
||||||
itemView.setOnClickListener {
|
|
||||||
itemView.isEnabled = false
|
|
||||||
loadMoreButton.isEnabled = false
|
|
||||||
loadMoreButton.icon = drawable
|
|
||||||
loadMoreButton.text = ""
|
|
||||||
listener.onLoadMore(bindingAdapterPosition)
|
listener.onLoadMore(bindingAdapterPosition)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun setup(loading: Boolean) {
|
||||||
|
binding.loadMoreButton.visible(!loading)
|
||||||
|
binding.loadMoreProgressBar.visible(loading)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,6 +2,9 @@ package com.keylesspalace.tusky.appstore
|
||||||
|
|
||||||
import com.keylesspalace.tusky.db.AccountManager
|
import com.keylesspalace.tusky.db.AccountManager
|
||||||
import com.keylesspalace.tusky.db.AppDatabase
|
import com.keylesspalace.tusky.db.AppDatabase
|
||||||
|
import com.keylesspalace.tusky.entity.Poll
|
||||||
|
import com.squareup.moshi.Moshi
|
||||||
|
import com.squareup.moshi.adapter
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
@ -9,40 +12,64 @@ import kotlinx.coroutines.SupervisorJob
|
||||||
import kotlinx.coroutines.cancel
|
import kotlinx.coroutines.cancel
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates the database cache in response to events.
|
||||||
|
* This is important for the home timeline and notifications to be up to date.
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalStdlibApi::class)
|
||||||
class CacheUpdater @Inject constructor(
|
class CacheUpdater @Inject constructor(
|
||||||
eventHub: EventHub,
|
eventHub: EventHub,
|
||||||
accountManager: AccountManager,
|
accountManager: AccountManager,
|
||||||
appDatabase: AppDatabase
|
appDatabase: AppDatabase,
|
||||||
|
moshi: Moshi
|
||||||
) {
|
) {
|
||||||
|
|
||||||
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||||
|
|
||||||
init {
|
private val timelineDao = appDatabase.timelineDao()
|
||||||
val timelineDao = appDatabase.timelineDao()
|
private val statusDao = appDatabase.timelineStatusDao()
|
||||||
|
private val notificationsDao = appDatabase.notificationsDao()
|
||||||
|
|
||||||
|
init {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
eventHub.events.collect { event ->
|
eventHub.events.collect { event ->
|
||||||
val accountId = accountManager.activeAccount?.id ?: return@collect
|
val tuskyAccountId = accountManager.activeAccount?.id ?: return@collect
|
||||||
when (event) {
|
when (event) {
|
||||||
is StatusChangedEvent -> {
|
is StatusChangedEvent -> statusDao.update(
|
||||||
val status = event.status
|
tuskyAccountId = tuskyAccountId,
|
||||||
timelineDao.update(
|
status = event.status,
|
||||||
accountId = accountId,
|
moshi = moshi
|
||||||
status = status
|
)
|
||||||
)
|
|
||||||
|
is UnfollowEvent -> timelineDao.removeStatusesAndReblogsByUser(tuskyAccountId, event.accountId)
|
||||||
|
|
||||||
|
is BlockEvent -> removeAllByUser(tuskyAccountId, event.accountId)
|
||||||
|
is MuteEvent -> removeAllByUser(tuskyAccountId, event.accountId)
|
||||||
|
|
||||||
|
is DomainMuteEvent -> {
|
||||||
|
timelineDao.deleteAllFromInstance(tuskyAccountId, event.instance)
|
||||||
|
notificationsDao.deleteAllFromInstance(tuskyAccountId, event.instance)
|
||||||
}
|
}
|
||||||
is UnfollowEvent ->
|
|
||||||
timelineDao.removeAllByUser(accountId, event.accountId)
|
is StatusDeletedEvent -> {
|
||||||
is StatusDeletedEvent ->
|
timelineDao.deleteAllWithStatus(tuskyAccountId, event.statusId)
|
||||||
timelineDao.delete(accountId, event.statusId)
|
notificationsDao.deleteAllWithStatus(tuskyAccountId, event.statusId)
|
||||||
|
}
|
||||||
|
|
||||||
is PollVoteEvent -> {
|
is PollVoteEvent -> {
|
||||||
timelineDao.setVoted(accountId, event.statusId, event.poll)
|
val pollString = moshi.adapter<Poll>().toJson(event.poll)
|
||||||
|
statusDao.setVoted(tuskyAccountId, event.statusId, pollString)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private suspend fun removeAllByUser(tuskyAccountId: Long, accountId: String) {
|
||||||
|
timelineDao.removeAllByUser(tuskyAccountId, accountId)
|
||||||
|
notificationsDao.removeAllByUser(tuskyAccountId, accountId)
|
||||||
|
}
|
||||||
|
|
||||||
fun stop() {
|
fun stop() {
|
||||||
this.scope.cancel()
|
this.scope.cancel()
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,14 +1,10 @@
|
||||||
package com.keylesspalace.tusky.appstore
|
package com.keylesspalace.tusky.appstore
|
||||||
|
|
||||||
import androidx.lifecycle.LifecycleOwner
|
|
||||||
import androidx.lifecycle.lifecycleScope
|
|
||||||
import java.util.function.Consumer
|
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
import kotlinx.coroutines.flow.SharedFlow
|
import kotlinx.coroutines.flow.SharedFlow
|
||||||
import kotlinx.coroutines.flow.asSharedFlow
|
import kotlinx.coroutines.flow.asSharedFlow
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
|
|
||||||
interface Event
|
interface Event
|
||||||
|
|
||||||
|
@ -21,13 +17,4 @@ class EventHub @Inject constructor() {
|
||||||
suspend fun dispatch(event: Event) {
|
suspend fun dispatch(event: Event) {
|
||||||
_events.emit(event)
|
_events.emit(event)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO remove as soon as NotificationsFragment is Kotlin
|
|
||||||
fun subscribe(lifecycleOwner: LifecycleOwner, consumer: Consumer<Event>) {
|
|
||||||
lifecycleOwner.lifecycleScope.launch {
|
|
||||||
events.collect { event ->
|
|
||||||
consumer.accept(event)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -71,8 +71,8 @@ import com.keylesspalace.tusky.components.accountlist.AccountListActivity
|
||||||
import com.keylesspalace.tusky.components.compose.ComposeActivity
|
import com.keylesspalace.tusky.components.compose.ComposeActivity
|
||||||
import com.keylesspalace.tusky.components.report.ReportActivity
|
import com.keylesspalace.tusky.components.report.ReportActivity
|
||||||
import com.keylesspalace.tusky.databinding.ActivityAccountBinding
|
import com.keylesspalace.tusky.databinding.ActivityAccountBinding
|
||||||
import com.keylesspalace.tusky.db.AccountEntity
|
|
||||||
import com.keylesspalace.tusky.db.DraftsAlert
|
import com.keylesspalace.tusky.db.DraftsAlert
|
||||||
|
import com.keylesspalace.tusky.db.entity.AccountEntity
|
||||||
import com.keylesspalace.tusky.di.ViewModelFactory
|
import com.keylesspalace.tusky.di.ViewModelFactory
|
||||||
import com.keylesspalace.tusky.entity.Account
|
import com.keylesspalace.tusky.entity.Account
|
||||||
import com.keylesspalace.tusky.entity.Relationship
|
import com.keylesspalace.tusky.entity.Relationship
|
||||||
|
|
|
@ -20,7 +20,7 @@ import androidx.paging.LoadType
|
||||||
import androidx.paging.PagingState
|
import androidx.paging.PagingState
|
||||||
import androidx.paging.RemoteMediator
|
import androidx.paging.RemoteMediator
|
||||||
import com.keylesspalace.tusky.components.timeline.util.ifExpected
|
import com.keylesspalace.tusky.components.timeline.util.ifExpected
|
||||||
import com.keylesspalace.tusky.db.AccountEntity
|
import com.keylesspalace.tusky.db.entity.AccountEntity
|
||||||
import com.keylesspalace.tusky.network.MastodonApi
|
import com.keylesspalace.tusky.network.MastodonApi
|
||||||
import com.keylesspalace.tusky.viewdata.AttachmentViewData
|
import com.keylesspalace.tusky.viewdata.AttachmentViewData
|
||||||
import retrofit2.HttpException
|
import retrofit2.HttpException
|
||||||
|
|
|
@ -44,6 +44,7 @@ class FollowRequestsAdapter(
|
||||||
)
|
)
|
||||||
return FollowRequestViewHolder(
|
return FollowRequestViewHolder(
|
||||||
binding,
|
binding,
|
||||||
|
accountActionListener,
|
||||||
linkListener,
|
linkListener,
|
||||||
showHeader = false
|
showHeader = false
|
||||||
)
|
)
|
||||||
|
|
|
@ -87,8 +87,8 @@ import com.keylesspalace.tusky.components.compose.view.ComposeOptionsListener
|
||||||
import com.keylesspalace.tusky.components.compose.view.ComposeScheduleView
|
import com.keylesspalace.tusky.components.compose.view.ComposeScheduleView
|
||||||
import com.keylesspalace.tusky.components.instanceinfo.InstanceInfoRepository
|
import com.keylesspalace.tusky.components.instanceinfo.InstanceInfoRepository
|
||||||
import com.keylesspalace.tusky.databinding.ActivityComposeBinding
|
import com.keylesspalace.tusky.databinding.ActivityComposeBinding
|
||||||
import com.keylesspalace.tusky.db.AccountEntity
|
import com.keylesspalace.tusky.db.entity.AccountEntity
|
||||||
import com.keylesspalace.tusky.db.DraftAttachment
|
import com.keylesspalace.tusky.db.entity.DraftAttachment
|
||||||
import com.keylesspalace.tusky.di.Injectable
|
import com.keylesspalace.tusky.di.Injectable
|
||||||
import com.keylesspalace.tusky.di.ViewModelFactory
|
import com.keylesspalace.tusky.di.ViewModelFactory
|
||||||
import com.keylesspalace.tusky.entity.Attachment
|
import com.keylesspalace.tusky.entity.Attachment
|
||||||
|
|
|
@ -126,7 +126,7 @@ data class ConversationStatusEntity(
|
||||||
visibility = Status.Visibility.DIRECT,
|
visibility = Status.Visibility.DIRECT,
|
||||||
attachments = attachments,
|
attachments = attachments,
|
||||||
mentions = mentions,
|
mentions = mentions,
|
||||||
tags = tags,
|
tags = tags.orEmpty(),
|
||||||
application = null,
|
application = null,
|
||||||
pinned = false,
|
pinned = false,
|
||||||
muted = muted,
|
muted = muted,
|
||||||
|
@ -148,7 +148,7 @@ fun TimelineAccount.toEntity() = ConversationAccountEntity(
|
||||||
username = username,
|
username = username,
|
||||||
displayName = name,
|
displayName = name,
|
||||||
avatar = avatar,
|
avatar = avatar,
|
||||||
emojis = emojis.orEmpty()
|
emojis = emojis
|
||||||
)
|
)
|
||||||
|
|
||||||
fun Status.toEntity(expanded: Boolean, contentShowing: Boolean, contentCollapsed: Boolean) =
|
fun Status.toEntity(expanded: Boolean, contentShowing: Boolean, contentCollapsed: Boolean) =
|
||||||
|
|
|
@ -23,8 +23,8 @@ import androidx.core.content.FileProvider
|
||||||
import androidx.core.net.toUri
|
import androidx.core.net.toUri
|
||||||
import com.keylesspalace.tusky.BuildConfig
|
import com.keylesspalace.tusky.BuildConfig
|
||||||
import com.keylesspalace.tusky.db.AppDatabase
|
import com.keylesspalace.tusky.db.AppDatabase
|
||||||
import com.keylesspalace.tusky.db.DraftAttachment
|
import com.keylesspalace.tusky.db.entity.DraftAttachment
|
||||||
import com.keylesspalace.tusky.db.DraftEntity
|
import com.keylesspalace.tusky.db.entity.DraftEntity
|
||||||
import com.keylesspalace.tusky.entity.Attachment
|
import com.keylesspalace.tusky.entity.Attachment
|
||||||
import com.keylesspalace.tusky.entity.NewPoll
|
import com.keylesspalace.tusky.entity.NewPoll
|
||||||
import com.keylesspalace.tusky.entity.Status
|
import com.keylesspalace.tusky.entity.Status
|
||||||
|
|
|
@ -24,7 +24,7 @@ import androidx.recyclerview.widget.RecyclerView
|
||||||
import com.bumptech.glide.Glide
|
import com.bumptech.glide.Glide
|
||||||
import com.bumptech.glide.load.engine.DiskCacheStrategy
|
import com.bumptech.glide.load.engine.DiskCacheStrategy
|
||||||
import com.keylesspalace.tusky.R
|
import com.keylesspalace.tusky.R
|
||||||
import com.keylesspalace.tusky.db.DraftAttachment
|
import com.keylesspalace.tusky.db.entity.DraftAttachment
|
||||||
import com.keylesspalace.tusky.view.MediaPreviewImageView
|
import com.keylesspalace.tusky.view.MediaPreviewImageView
|
||||||
|
|
||||||
class DraftMediaAdapter(
|
class DraftMediaAdapter(
|
||||||
|
|
|
@ -32,8 +32,8 @@ import com.keylesspalace.tusky.BaseActivity
|
||||||
import com.keylesspalace.tusky.R
|
import com.keylesspalace.tusky.R
|
||||||
import com.keylesspalace.tusky.components.compose.ComposeActivity
|
import com.keylesspalace.tusky.components.compose.ComposeActivity
|
||||||
import com.keylesspalace.tusky.databinding.ActivityDraftsBinding
|
import com.keylesspalace.tusky.databinding.ActivityDraftsBinding
|
||||||
import com.keylesspalace.tusky.db.DraftEntity
|
|
||||||
import com.keylesspalace.tusky.db.DraftsAlert
|
import com.keylesspalace.tusky.db.DraftsAlert
|
||||||
|
import com.keylesspalace.tusky.db.entity.DraftEntity
|
||||||
import com.keylesspalace.tusky.di.ViewModelFactory
|
import com.keylesspalace.tusky.di.ViewModelFactory
|
||||||
import com.keylesspalace.tusky.util.isHttpNotFound
|
import com.keylesspalace.tusky.util.isHttpNotFound
|
||||||
import com.keylesspalace.tusky.util.parseAsMastodonHtml
|
import com.keylesspalace.tusky.util.parseAsMastodonHtml
|
||||||
|
|
|
@ -22,7 +22,7 @@ import androidx.recyclerview.widget.DiffUtil
|
||||||
import androidx.recyclerview.widget.LinearLayoutManager
|
import androidx.recyclerview.widget.LinearLayoutManager
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
import com.keylesspalace.tusky.databinding.ItemDraftBinding
|
import com.keylesspalace.tusky.databinding.ItemDraftBinding
|
||||||
import com.keylesspalace.tusky.db.DraftEntity
|
import com.keylesspalace.tusky.db.entity.DraftEntity
|
||||||
import com.keylesspalace.tusky.util.BindingHolder
|
import com.keylesspalace.tusky.util.BindingHolder
|
||||||
import com.keylesspalace.tusky.util.hide
|
import com.keylesspalace.tusky.util.hide
|
||||||
import com.keylesspalace.tusky.util.show
|
import com.keylesspalace.tusky.util.show
|
||||||
|
|
|
@ -23,7 +23,7 @@ import androidx.paging.cachedIn
|
||||||
import at.connyduck.calladapter.networkresult.NetworkResult
|
import at.connyduck.calladapter.networkresult.NetworkResult
|
||||||
import com.keylesspalace.tusky.db.AccountManager
|
import com.keylesspalace.tusky.db.AccountManager
|
||||||
import com.keylesspalace.tusky.db.AppDatabase
|
import com.keylesspalace.tusky.db.AppDatabase
|
||||||
import com.keylesspalace.tusky.db.DraftEntity
|
import com.keylesspalace.tusky.db.entity.DraftEntity
|
||||||
import com.keylesspalace.tusky.entity.Status
|
import com.keylesspalace.tusky.entity.Status
|
||||||
import com.keylesspalace.tusky.network.MastodonApi
|
import com.keylesspalace.tusky.network.MastodonApi
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
|
@ -24,8 +24,8 @@ import at.connyduck.calladapter.networkresult.onSuccess
|
||||||
import at.connyduck.calladapter.networkresult.recoverCatching
|
import at.connyduck.calladapter.networkresult.recoverCatching
|
||||||
import com.keylesspalace.tusky.db.AccountManager
|
import com.keylesspalace.tusky.db.AccountManager
|
||||||
import com.keylesspalace.tusky.db.AppDatabase
|
import com.keylesspalace.tusky.db.AppDatabase
|
||||||
import com.keylesspalace.tusky.db.EmojisEntity
|
import com.keylesspalace.tusky.db.entity.EmojisEntity
|
||||||
import com.keylesspalace.tusky.db.InstanceInfoEntity
|
import com.keylesspalace.tusky.db.entity.InstanceInfoEntity
|
||||||
import com.keylesspalace.tusky.di.ApplicationScope
|
import com.keylesspalace.tusky.di.ApplicationScope
|
||||||
import com.keylesspalace.tusky.entity.Emoji
|
import com.keylesspalace.tusky.entity.Emoji
|
||||||
import com.keylesspalace.tusky.entity.Instance
|
import com.keylesspalace.tusky.entity.Instance
|
||||||
|
|
|
@ -0,0 +1,69 @@
|
||||||
|
/* Copyright 2024 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.notifications
|
||||||
|
|
||||||
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
|
import com.keylesspalace.tusky.R
|
||||||
|
import com.keylesspalace.tusky.databinding.ItemFollowBinding
|
||||||
|
import com.keylesspalace.tusky.entity.Notification
|
||||||
|
import com.keylesspalace.tusky.interfaces.AccountActionListener
|
||||||
|
import com.keylesspalace.tusky.util.StatusDisplayOptions
|
||||||
|
import com.keylesspalace.tusky.util.emojify
|
||||||
|
import com.keylesspalace.tusky.util.loadAvatar
|
||||||
|
import com.keylesspalace.tusky.util.unicodeWrap
|
||||||
|
import com.keylesspalace.tusky.viewdata.NotificationViewData
|
||||||
|
|
||||||
|
class FollowViewHolder(
|
||||||
|
private val binding: ItemFollowBinding,
|
||||||
|
private val listener: AccountActionListener,
|
||||||
|
) : RecyclerView.ViewHolder(binding.root), NotificationsViewHolder {
|
||||||
|
|
||||||
|
override fun bind(
|
||||||
|
viewData: NotificationViewData.Concrete,
|
||||||
|
payloads: List<*>,
|
||||||
|
statusDisplayOptions: StatusDisplayOptions
|
||||||
|
) {
|
||||||
|
val context = itemView.context
|
||||||
|
val account = viewData.account
|
||||||
|
val messageTemplate =
|
||||||
|
context.getString(if (viewData.type == Notification.Type.SIGN_UP) R.string.notification_sign_up_format else R.string.notification_follow_format)
|
||||||
|
val wrappedDisplayName = account.name.unicodeWrap()
|
||||||
|
|
||||||
|
binding.notificationText.text = messageTemplate.format(wrappedDisplayName)
|
||||||
|
.emojify(account.emojis, binding.notificationText, statusDisplayOptions.animateEmojis)
|
||||||
|
|
||||||
|
binding.notificationUsername.text = context.getString(R.string.post_username_format, viewData.account.username)
|
||||||
|
|
||||||
|
val emojifiedDisplayName = wrappedDisplayName.emojify(
|
||||||
|
account.emojis,
|
||||||
|
binding.notificationDisplayName,
|
||||||
|
statusDisplayOptions.animateEmojis
|
||||||
|
)
|
||||||
|
binding.notificationDisplayName.text = emojifiedDisplayName
|
||||||
|
|
||||||
|
val avatarRadius = context.resources
|
||||||
|
.getDimensionPixelSize(R.dimen.avatar_radius_42dp)
|
||||||
|
loadAvatar(
|
||||||
|
account.avatar,
|
||||||
|
binding.notificationAvatar,
|
||||||
|
avatarRadius,
|
||||||
|
statusDisplayOptions.animateAvatars,
|
||||||
|
null
|
||||||
|
)
|
||||||
|
|
||||||
|
itemView.setOnClickListener { listener.onViewAccount(account.id) }
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,104 @@
|
||||||
|
/* Copyright 2024 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.notifications
|
||||||
|
|
||||||
|
import com.keylesspalace.tusky.components.timeline.Placeholder
|
||||||
|
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.Notification
|
||||||
|
import com.keylesspalace.tusky.entity.Report
|
||||||
|
import com.keylesspalace.tusky.viewdata.NotificationViewData
|
||||||
|
import com.keylesspalace.tusky.viewdata.StatusViewData
|
||||||
|
import com.keylesspalace.tusky.viewdata.TranslationViewData
|
||||||
|
|
||||||
|
fun Placeholder.toNotificationEntity(
|
||||||
|
tuskyAccountId: Long
|
||||||
|
) = NotificationEntity(
|
||||||
|
id = this.id,
|
||||||
|
tuskyAccountId = tuskyAccountId,
|
||||||
|
type = null,
|
||||||
|
accountId = null,
|
||||||
|
statusId = null,
|
||||||
|
reportId = null,
|
||||||
|
loading = loading
|
||||||
|
)
|
||||||
|
|
||||||
|
fun Notification.toEntity(
|
||||||
|
tuskyAccountId: Long
|
||||||
|
) = NotificationEntity(
|
||||||
|
tuskyAccountId = tuskyAccountId,
|
||||||
|
type = type,
|
||||||
|
id = id,
|
||||||
|
accountId = account.id,
|
||||||
|
statusId = status?.id,
|
||||||
|
reportId = report?.id,
|
||||||
|
loading = false
|
||||||
|
)
|
||||||
|
|
||||||
|
fun Report.toEntity(
|
||||||
|
tuskyAccountId: Long
|
||||||
|
) = NotificationReportEntity(
|
||||||
|
tuskyAccountId = tuskyAccountId,
|
||||||
|
serverId = id,
|
||||||
|
category = category,
|
||||||
|
statusIds = statusIds,
|
||||||
|
createdAt = createdAt,
|
||||||
|
targetAccountId = targetAccount.id
|
||||||
|
)
|
||||||
|
|
||||||
|
fun NotificationDataEntity.toViewData(
|
||||||
|
translation: TranslationViewData? = null
|
||||||
|
): NotificationViewData {
|
||||||
|
if (type == null || account == null) {
|
||||||
|
return NotificationViewData.Placeholder(id = id, isLoading = loading)
|
||||||
|
}
|
||||||
|
|
||||||
|
return NotificationViewData.Concrete(
|
||||||
|
id = id,
|
||||||
|
type = type,
|
||||||
|
account = account.toAccount(),
|
||||||
|
statusViewData = if (status != null && statusAccount != null) {
|
||||||
|
StatusViewData.Concrete(
|
||||||
|
status = status.toStatus(statusAccount),
|
||||||
|
isExpanded = this.status.expanded,
|
||||||
|
isShowingContent = this.status.contentShowing,
|
||||||
|
isCollapsed = this.status.contentCollapsed,
|
||||||
|
translation = translation
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
},
|
||||||
|
report = if (report != null && reportTargetAccount != null) {
|
||||||
|
report.toReport(reportTargetAccount)
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun NotificationReportEntity.toReport(
|
||||||
|
account: TimelineAccountEntity
|
||||||
|
) = Report(
|
||||||
|
id = serverId,
|
||||||
|
category = category,
|
||||||
|
statusIds = statusIds,
|
||||||
|
createdAt = createdAt,
|
||||||
|
targetAccount = account.toAccount()
|
||||||
|
)
|
|
@ -0,0 +1,574 @@
|
||||||
|
/* Copyright 2024 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.notifications
|
||||||
|
|
||||||
|
import android.content.DialogInterface
|
||||||
|
import android.content.SharedPreferences
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.view.LayoutInflater
|
||||||
|
import android.view.Menu
|
||||||
|
import android.view.MenuInflater
|
||||||
|
import android.view.MenuItem
|
||||||
|
import android.view.View
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import android.widget.ArrayAdapter
|
||||||
|
import android.widget.ListView
|
||||||
|
import android.widget.PopupWindow
|
||||||
|
import androidx.appcompat.app.AlertDialog
|
||||||
|
import androidx.coordinatorlayout.widget.CoordinatorLayout
|
||||||
|
import androidx.core.view.MenuProvider
|
||||||
|
import androidx.fragment.app.viewModels
|
||||||
|
import androidx.lifecycle.Lifecycle
|
||||||
|
import androidx.lifecycle.lifecycleScope
|
||||||
|
import androidx.lifecycle.repeatOnLifecycle
|
||||||
|
import androidx.paging.LoadState
|
||||||
|
import androidx.recyclerview.widget.DividerItemDecoration
|
||||||
|
import androidx.recyclerview.widget.LinearLayoutManager
|
||||||
|
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.helpers.Utils
|
||||||
|
import com.google.android.material.appbar.AppBarLayout
|
||||||
|
import com.google.android.material.snackbar.Snackbar
|
||||||
|
import com.keylesspalace.tusky.R
|
||||||
|
import com.keylesspalace.tusky.adapter.StatusBaseViewHolder
|
||||||
|
import com.keylesspalace.tusky.appstore.EventHub
|
||||||
|
import com.keylesspalace.tusky.appstore.PreferenceChangedEvent
|
||||||
|
import com.keylesspalace.tusky.components.preference.PreferencesFragment.ReadingOrder
|
||||||
|
import com.keylesspalace.tusky.components.systemnotifications.NotificationHelper
|
||||||
|
import com.keylesspalace.tusky.databinding.FragmentTimelineNotificationsBinding
|
||||||
|
import com.keylesspalace.tusky.databinding.NotificationsFilterBinding
|
||||||
|
import com.keylesspalace.tusky.di.Injectable
|
||||||
|
import com.keylesspalace.tusky.di.ViewModelFactory
|
||||||
|
import com.keylesspalace.tusky.entity.Notification
|
||||||
|
import com.keylesspalace.tusky.fragment.SFragment
|
||||||
|
import com.keylesspalace.tusky.interfaces.AccountActionListener
|
||||||
|
import com.keylesspalace.tusky.interfaces.ActionButtonActivity
|
||||||
|
import com.keylesspalace.tusky.interfaces.ReselectableFragment
|
||||||
|
import com.keylesspalace.tusky.interfaces.StatusActionListener
|
||||||
|
import com.keylesspalace.tusky.settings.PrefKeys
|
||||||
|
import com.keylesspalace.tusky.util.CardViewMode
|
||||||
|
import com.keylesspalace.tusky.util.ListStatusAccessibilityDelegate
|
||||||
|
import com.keylesspalace.tusky.util.StatusDisplayOptions
|
||||||
|
import com.keylesspalace.tusky.util.StatusProvider
|
||||||
|
import com.keylesspalace.tusky.util.hide
|
||||||
|
import com.keylesspalace.tusky.util.openLink
|
||||||
|
import com.keylesspalace.tusky.util.show
|
||||||
|
import com.keylesspalace.tusky.util.viewBinding
|
||||||
|
import com.keylesspalace.tusky.viewdata.AttachmentViewData
|
||||||
|
import com.keylesspalace.tusky.viewdata.NotificationViewData
|
||||||
|
import com.keylesspalace.tusky.viewdata.TranslationViewData
|
||||||
|
import javax.inject.Inject
|
||||||
|
import kotlin.time.DurationUnit
|
||||||
|
import kotlin.time.toDuration
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.flow.collectLatest
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
class NotificationsFragment :
|
||||||
|
SFragment(),
|
||||||
|
SwipeRefreshLayout.OnRefreshListener,
|
||||||
|
StatusActionListener,
|
||||||
|
NotificationActionListener,
|
||||||
|
AccountActionListener,
|
||||||
|
MenuProvider,
|
||||||
|
ReselectableFragment,
|
||||||
|
Injectable {
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
lateinit var viewModelFactory: ViewModelFactory
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
lateinit var preferences: SharedPreferences
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
lateinit var eventHub: EventHub
|
||||||
|
|
||||||
|
private val binding by viewBinding(FragmentTimelineNotificationsBinding::bind)
|
||||||
|
|
||||||
|
private val viewModel: NotificationsViewModel by viewModels { viewModelFactory }
|
||||||
|
|
||||||
|
private lateinit var adapter: NotificationsPagingAdapter
|
||||||
|
|
||||||
|
private var hideFab: Boolean = false
|
||||||
|
private var showNotificationsFilterBar: Boolean = true
|
||||||
|
private var readingOrder: ReadingOrder = ReadingOrder.NEWEST_FIRST
|
||||||
|
|
||||||
|
/** see [com.keylesspalace.tusky.components.timeline.TimelineFragment] for explanation of the load more mechanism */
|
||||||
|
private var loadMorePosition: Int? = null
|
||||||
|
private var statusIdBelowLoadMore: String? = null
|
||||||
|
|
||||||
|
override fun onCreateView(
|
||||||
|
inflater: LayoutInflater,
|
||||||
|
container: ViewGroup?,
|
||||||
|
savedInstanceState: Bundle?
|
||||||
|
): View? {
|
||||||
|
return inflater.inflate(R.layout.fragment_timeline_notifications, container, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
|
val statusDisplayOptions = StatusDisplayOptions(
|
||||||
|
animateAvatars = preferences.getBoolean(PrefKeys.ANIMATE_GIF_AVATARS, false),
|
||||||
|
mediaPreviewEnabled = accountManager.activeAccount!!.mediaPreviewEnabled,
|
||||||
|
useAbsoluteTime = preferences.getBoolean(PrefKeys.ABSOLUTE_TIME_VIEW, false),
|
||||||
|
showBotOverlay = preferences.getBoolean(PrefKeys.SHOW_BOT_OVERLAY, true),
|
||||||
|
useBlurhash = preferences.getBoolean(PrefKeys.USE_BLURHASH, true),
|
||||||
|
cardViewMode = if (preferences.getBoolean(PrefKeys.SHOW_CARDS_IN_TIMELINES, false)) {
|
||||||
|
CardViewMode.INDENTED
|
||||||
|
} 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),
|
||||||
|
showSensitiveMedia = accountManager.activeAccount!!.alwaysShowSensitiveMedia,
|
||||||
|
openSpoiler = accountManager.activeAccount!!.alwaysOpenSpoiler
|
||||||
|
)
|
||||||
|
|
||||||
|
// setup the notifications filter bar
|
||||||
|
showNotificationsFilterBar = preferences.getBoolean(PrefKeys.SHOW_NOTIFICATIONS_FILTER, true)
|
||||||
|
updateFilterBarVisibility()
|
||||||
|
binding.buttonClear.setOnClickListener { confirmClearNotifications() }
|
||||||
|
binding.buttonFilter.setOnClickListener { showFilterMenu() }
|
||||||
|
|
||||||
|
// Setup the SwipeRefreshLayout.
|
||||||
|
binding.swipeRefreshLayout.setOnRefreshListener(this)
|
||||||
|
binding.swipeRefreshLayout.setColorSchemeResources(R.color.tusky_blue)
|
||||||
|
|
||||||
|
// Setup the RecyclerView.
|
||||||
|
binding.recyclerView.setHasFixedSize(true)
|
||||||
|
adapter = NotificationsPagingAdapter(
|
||||||
|
accountId = accountManager.activeAccount!!.accountId,
|
||||||
|
statusListener = this,
|
||||||
|
notificationActionListener = this,
|
||||||
|
accountActionListener = this,
|
||||||
|
statusDisplayOptions = statusDisplayOptions
|
||||||
|
)
|
||||||
|
binding.recyclerView.layoutManager = LinearLayoutManager(context)
|
||||||
|
binding.recyclerView.setAccessibilityDelegateCompat(
|
||||||
|
ListStatusAccessibilityDelegate(
|
||||||
|
binding.recyclerView,
|
||||||
|
this,
|
||||||
|
StatusProvider { pos: Int ->
|
||||||
|
if (pos in 0 until adapter.itemCount) {
|
||||||
|
val notification = adapter.peek(pos)
|
||||||
|
// We support replies only for now
|
||||||
|
if (notification is NotificationViewData.Concrete) {
|
||||||
|
return@StatusProvider notification.statusViewData
|
||||||
|
} else {
|
||||||
|
return@StatusProvider null
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
binding.recyclerView.adapter = adapter
|
||||||
|
|
||||||
|
(binding.recyclerView.itemAnimator as SimpleItemAnimator).supportsChangeAnimations = false
|
||||||
|
|
||||||
|
binding.recyclerView.addItemDecoration(
|
||||||
|
DividerItemDecoration(context, DividerItemDecoration.VERTICAL)
|
||||||
|
)
|
||||||
|
|
||||||
|
hideFab = preferences.getBoolean(PrefKeys.FAB_HIDE, false)
|
||||||
|
readingOrder = ReadingOrder.from(preferences.getString(PrefKeys.READING_ORDER, null))
|
||||||
|
|
||||||
|
binding.recyclerView.addOnScrollListener(object : RecyclerView.OnScrollListener() {
|
||||||
|
override fun onScrolled(view: RecyclerView, dx: Int, dy: Int) {
|
||||||
|
val composeButton = (activity as ActionButtonActivity).actionButton
|
||||||
|
if (composeButton != null) {
|
||||||
|
if (hideFab) {
|
||||||
|
if (dy > 0 && composeButton.isShown) {
|
||||||
|
composeButton.hide() // hides the button if we're scrolling down
|
||||||
|
} else if (dy < 0 && !composeButton.isShown) {
|
||||||
|
composeButton.show() // shows it if we are scrolling up
|
||||||
|
}
|
||||||
|
} else if (!composeButton.isShown) {
|
||||||
|
composeButton.show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
adapter.addLoadStateListener { loadState ->
|
||||||
|
if (loadState.refresh != LoadState.Loading && loadState.source.refresh != LoadState.Loading) {
|
||||||
|
binding.swipeRefreshLayout.isRefreshing = false
|
||||||
|
}
|
||||||
|
|
||||||
|
binding.statusView.hide()
|
||||||
|
binding.progressBar.hide()
|
||||||
|
|
||||||
|
if (adapter.itemCount == 0) {
|
||||||
|
when (loadState.refresh) {
|
||||||
|
is LoadState.NotLoading -> {
|
||||||
|
if (loadState.append is LoadState.NotLoading && loadState.source.refresh is LoadState.NotLoading) {
|
||||||
|
binding.statusView.show()
|
||||||
|
binding.statusView.setup(R.drawable.elephant_friend_empty, R.string.message_empty)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is LoadState.Error -> {
|
||||||
|
binding.statusView.show()
|
||||||
|
binding.statusView.setup((loadState.refresh as LoadState.Error).error) { onRefresh() }
|
||||||
|
}
|
||||||
|
is LoadState.Loading -> {
|
||||||
|
binding.progressBar.show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
adapter.registerAdapterDataObserver(object : RecyclerView.AdapterDataObserver() {
|
||||||
|
override fun onItemRangeInserted(positionStart: Int, itemCount: Int) {
|
||||||
|
if (positionStart == 0 && adapter.itemCount != itemCount) {
|
||||||
|
binding.recyclerView.post {
|
||||||
|
if (getView() != null) {
|
||||||
|
binding.recyclerView.scrollBy(
|
||||||
|
0,
|
||||||
|
Utils.dpToPx(binding.recyclerView.context, -30)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (readingOrder == ReadingOrder.OLDEST_FIRST) {
|
||||||
|
updateReadingPositionForOldestFirst()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
viewLifecycleOwner.lifecycleScope.launch {
|
||||||
|
viewModel.notifications.collectLatest { pagingData ->
|
||||||
|
adapter.submitData(pagingData)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
viewLifecycleOwner.lifecycleScope.launch {
|
||||||
|
eventHub.events.collect { event ->
|
||||||
|
if (event is PreferenceChangedEvent) {
|
||||||
|
onPreferenceChanged(event.preferenceKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
viewLifecycleOwner.lifecycleScope.launch {
|
||||||
|
repeatOnLifecycle(Lifecycle.State.RESUMED) {
|
||||||
|
accountManager.activeAccount?.let { account ->
|
||||||
|
NotificationHelper.clearNotificationsForAccount(requireContext(), account)
|
||||||
|
}
|
||||||
|
|
||||||
|
val useAbsoluteTime = preferences.getBoolean(PrefKeys.ABSOLUTE_TIME_VIEW, false)
|
||||||
|
while (!useAbsoluteTime) {
|
||||||
|
adapter.notifyItemRangeChanged(
|
||||||
|
0,
|
||||||
|
adapter.itemCount,
|
||||||
|
listOf(StatusBaseViewHolder.Key.KEY_CREATED)
|
||||||
|
)
|
||||||
|
delay(1.toDuration(DurationUnit.MINUTES))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onReselect() {
|
||||||
|
if (isAdded) {
|
||||||
|
binding.recyclerView.layoutManager?.scrollToPosition(0)
|
||||||
|
binding.recyclerView.stopScroll()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onRefresh() {
|
||||||
|
adapter.refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onViewAccount(id: String) {
|
||||||
|
super.viewAccount(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onMute(mute: Boolean, id: String, position: Int, notifications: Boolean) {
|
||||||
|
// not needed, muting via the more menu on statuses is handled in SFragment
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onBlock(block: Boolean, id: String, position: Int) {
|
||||||
|
// not needed, blocking via the more menu on statuses is handled in SFragment
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onRespondToFollowRequest(accept: Boolean, id: String, position: Int) {
|
||||||
|
val notification = adapter.peek(position) ?: return
|
||||||
|
viewModel.respondToFollowRequest(accept, accountId = id, notificationId = notification.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onViewReport(reportId: String?) {
|
||||||
|
requireContext().openLink(
|
||||||
|
"https://${accountManager.activeAccount!!.domain}/admin/reports/$reportId"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onViewTag(tag: String) {
|
||||||
|
super.viewTag(tag)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onReply(position: Int) {
|
||||||
|
val status = adapter.peek(position)?.asStatusOrNull() ?: return
|
||||||
|
super.reply(status.status)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun removeItem(position: Int) {
|
||||||
|
val notification = adapter.peek(position) ?: return
|
||||||
|
viewModel.remove(notification.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onReblog(reblog: Boolean, position: Int) {
|
||||||
|
val status = adapter.peek(position)?.asStatusOrNull() ?: return
|
||||||
|
viewModel.reblog(reblog, status)
|
||||||
|
}
|
||||||
|
|
||||||
|
override val onMoreTranslate: (translate: Boolean, position: Int) -> Unit
|
||||||
|
get() = { translate: Boolean, position: Int ->
|
||||||
|
if (translate) {
|
||||||
|
onTranslate(position)
|
||||||
|
} else {
|
||||||
|
onUntranslate(position)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun onTranslate(position: Int) {
|
||||||
|
val status = adapter.peek(position)?.asStatusOrNull() ?: return
|
||||||
|
lifecycleScope.launch {
|
||||||
|
viewModel.translate(status)
|
||||||
|
.onFailure {
|
||||||
|
Snackbar.make(
|
||||||
|
requireView(),
|
||||||
|
getString(R.string.ui_error_translate, it.message),
|
||||||
|
Snackbar.LENGTH_LONG
|
||||||
|
).show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onUntranslate(position: Int) {
|
||||||
|
val status = adapter.peek(position)?.asStatusOrNull() ?: return
|
||||||
|
viewModel.untranslate(status)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onFavourite(favourite: Boolean, position: Int) {
|
||||||
|
val status = adapter.peek(position)?.asStatusOrNull() ?: return
|
||||||
|
viewModel.favorite(favourite, status)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onBookmark(bookmark: Boolean, position: Int) {
|
||||||
|
val status = adapter.peek(position)?.asStatusOrNull() ?: return
|
||||||
|
viewModel.bookmark(bookmark, status)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onVoteInPoll(position: Int, choices: List<Int>) {
|
||||||
|
val status = adapter.peek(position)?.asStatusOrNull() ?: return
|
||||||
|
viewModel.voteInPoll(choices, status)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun clearWarningAction(position: Int) {
|
||||||
|
val status = adapter.peek(position)?.asStatusOrNull() ?: return
|
||||||
|
viewModel.clearWarning(status)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onMore(view: View, position: Int) {
|
||||||
|
val status = adapter.peek(position)?.asStatusOrNull() ?: return
|
||||||
|
super.more(
|
||||||
|
status.status,
|
||||||
|
view,
|
||||||
|
position,
|
||||||
|
(status.translation as? TranslationViewData.Loaded)?.data
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onViewMedia(position: Int, attachmentIndex: Int, view: View?) {
|
||||||
|
val status = adapter.peek(position)?.asStatusOrNull()?.status ?: return
|
||||||
|
super.viewMedia(attachmentIndex, AttachmentViewData.list(status), view)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onViewThread(position: Int) {
|
||||||
|
val status = adapter.peek(position)?.asStatusOrNull()?.status ?: return
|
||||||
|
super.viewThread(status.id, status.url)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onOpenReblog(position: Int) {
|
||||||
|
val status = adapter.peek(position)?.asStatusOrNull() ?: return
|
||||||
|
super.openReblog(status.status)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onExpandedChange(expanded: Boolean, position: Int) {
|
||||||
|
val status = adapter.peek(position)?.asStatusOrNull() ?: return
|
||||||
|
viewModel.changeExpanded(expanded, status)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onContentHiddenChange(isShowing: Boolean, position: Int) {
|
||||||
|
val status = adapter.peek(position)?.asStatusOrNull() ?: return
|
||||||
|
viewModel.changeContentShowing(isShowing, status)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onLoadMore(position: Int) {
|
||||||
|
val placeholder = adapter.peek(position)?.asPlaceholderOrNull() ?: return
|
||||||
|
loadMorePosition = position
|
||||||
|
statusIdBelowLoadMore =
|
||||||
|
if (position + 1 < adapter.itemCount) adapter.peek(position + 1)?.id else null
|
||||||
|
viewModel.loadMore(placeholder.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onContentCollapsedChange(isCollapsed: Boolean, position: Int) {
|
||||||
|
val status = adapter.peek(position)?.asStatusOrNull() ?: return
|
||||||
|
viewModel.changeContentCollapsed(isCollapsed, status)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun confirmClearNotifications() {
|
||||||
|
AlertDialog.Builder(requireContext())
|
||||||
|
.setMessage(R.string.notification_clear_text)
|
||||||
|
.setPositiveButton(android.R.string.ok) { _: DialogInterface?, _: Int -> clearNotifications() }
|
||||||
|
.setNegativeButton(android.R.string.cancel, null)
|
||||||
|
.show()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun clearNotifications() {
|
||||||
|
viewModel.clearNotifications()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun showFilterMenu() {
|
||||||
|
val notificationTypeList = Notification.Type.visibleTypes.map { type ->
|
||||||
|
getString(type.uiString)
|
||||||
|
}
|
||||||
|
|
||||||
|
val adapter = ArrayAdapter(requireContext(), android.R.layout.simple_list_item_multiple_choice, notificationTypeList)
|
||||||
|
val window = PopupWindow(requireContext())
|
||||||
|
val menuBinding = NotificationsFilterBinding.inflate(LayoutInflater.from(requireContext()), binding.root as ViewGroup, false)
|
||||||
|
|
||||||
|
menuBinding.buttonApply.setOnClickListener {
|
||||||
|
val checkedItems = menuBinding.listView.getCheckedItemPositions()
|
||||||
|
val excludes = Notification.Type.visibleTypes.filterIndexed { index, _ ->
|
||||||
|
!checkedItems[index, false]
|
||||||
|
}
|
||||||
|
window.dismiss()
|
||||||
|
viewModel.updateNotificationFilters(excludes.toSet())
|
||||||
|
}
|
||||||
|
|
||||||
|
menuBinding.listView.setAdapter(adapter)
|
||||||
|
menuBinding.listView.setChoiceMode(ListView.CHOICE_MODE_MULTIPLE)
|
||||||
|
|
||||||
|
Notification.Type.visibleTypes.forEachIndexed { index, type ->
|
||||||
|
menuBinding.listView.setItemChecked(index, !viewModel.filters.value.contains(type))
|
||||||
|
}
|
||||||
|
|
||||||
|
window.setContentView(menuBinding.root)
|
||||||
|
window.isFocusable = true
|
||||||
|
window.width = ViewGroup.LayoutParams.WRAP_CONTENT
|
||||||
|
window.height = ViewGroup.LayoutParams.WRAP_CONTENT
|
||||||
|
window.showAsDropDown(binding.buttonFilter)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun onPreferenceChanged(key: String) {
|
||||||
|
when (key) {
|
||||||
|
PrefKeys.FAB_HIDE -> {
|
||||||
|
hideFab = preferences.getBoolean(PrefKeys.FAB_HIDE, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
PrefKeys.MEDIA_PREVIEW_ENABLED -> {
|
||||||
|
val enabled = accountManager.activeAccount!!.mediaPreviewEnabled
|
||||||
|
val oldMediaPreviewEnabled = adapter.mediaPreviewEnabled
|
||||||
|
if (enabled != oldMediaPreviewEnabled) {
|
||||||
|
adapter.mediaPreviewEnabled = enabled
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
PrefKeys.SHOW_NOTIFICATIONS_FILTER -> {
|
||||||
|
if (isAdded) {
|
||||||
|
showNotificationsFilterBar = preferences.getBoolean(PrefKeys.SHOW_NOTIFICATIONS_FILTER, true)
|
||||||
|
updateFilterBarVisibility()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
PrefKeys.READING_ORDER -> {
|
||||||
|
readingOrder = ReadingOrder.from(
|
||||||
|
preferences.getString(PrefKeys.READING_ORDER, null)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun updateFilterBarVisibility() {
|
||||||
|
val params = binding.swipeRefreshLayout.layoutParams as CoordinatorLayout.LayoutParams
|
||||||
|
if (showNotificationsFilterBar) {
|
||||||
|
binding.appBarOptions.setExpanded(true, false)
|
||||||
|
binding.appBarOptions.show()
|
||||||
|
// Set content behaviour to hide filter on scroll
|
||||||
|
params.behavior = AppBarLayout.ScrollingViewBehavior()
|
||||||
|
} else {
|
||||||
|
binding.appBarOptions.setExpanded(false, false)
|
||||||
|
binding.appBarOptions.hide()
|
||||||
|
// Clear behaviour to hide app bar
|
||||||
|
params.behavior = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun updateReadingPositionForOldestFirst() {
|
||||||
|
var position = loadMorePosition ?: return
|
||||||
|
val notificationIdBelowLoadMore = statusIdBelowLoadMore ?: return
|
||||||
|
|
||||||
|
var notification: NotificationViewData?
|
||||||
|
while (adapter.peek(position).let {
|
||||||
|
notification = it
|
||||||
|
it != null
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
if (notification?.id == notificationIdBelowLoadMore) {
|
||||||
|
val lastVisiblePosition =
|
||||||
|
(binding.recyclerView.layoutManager as LinearLayoutManager).findLastVisibleItemPosition()
|
||||||
|
if (position > lastVisiblePosition) {
|
||||||
|
binding.recyclerView.scrollToPosition(position)
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
position++
|
||||||
|
}
|
||||||
|
loadMorePosition = null
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {
|
||||||
|
menuInflater.inflate(R.menu.fragment_notifications, menu)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onMenuItemSelected(menuItem: MenuItem) = when (menuItem.itemId) {
|
||||||
|
R.id.action_refresh -> {
|
||||||
|
binding.swipeRefreshLayout.isRefreshing = true
|
||||||
|
onRefresh()
|
||||||
|
true
|
||||||
|
}
|
||||||
|
R.id.action_edit_notification_filter -> {
|
||||||
|
showFilterMenu()
|
||||||
|
true
|
||||||
|
}
|
||||||
|
R.id.action_clear_notifications -> {
|
||||||
|
confirmClearNotifications()
|
||||||
|
true
|
||||||
|
}
|
||||||
|
else -> false
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun newInstance() = NotificationsFragment()
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,197 @@
|
||||||
|
/* Copyright 2024 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.notifications
|
||||||
|
|
||||||
|
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.adapter.FollowRequestViewHolder
|
||||||
|
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.ItemReportNotificationBinding
|
||||||
|
import com.keylesspalace.tusky.databinding.ItemStatusBinding
|
||||||
|
import com.keylesspalace.tusky.databinding.ItemStatusNotificationBinding
|
||||||
|
import com.keylesspalace.tusky.databinding.ItemStatusPlaceholderBinding
|
||||||
|
import com.keylesspalace.tusky.databinding.ItemUnknownNotificationBinding
|
||||||
|
import com.keylesspalace.tusky.entity.Notification
|
||||||
|
import com.keylesspalace.tusky.interfaces.AccountActionListener
|
||||||
|
import com.keylesspalace.tusky.interfaces.StatusActionListener
|
||||||
|
import com.keylesspalace.tusky.util.AbsoluteTimeFormatter
|
||||||
|
import com.keylesspalace.tusky.util.StatusDisplayOptions
|
||||||
|
import com.keylesspalace.tusky.viewdata.NotificationViewData
|
||||||
|
|
||||||
|
interface NotificationActionListener {
|
||||||
|
fun onViewReport(reportId: String?)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface NotificationsViewHolder {
|
||||||
|
fun bind(
|
||||||
|
viewData: NotificationViewData.Concrete,
|
||||||
|
payloads: List<*>,
|
||||||
|
statusDisplayOptions: StatusDisplayOptions
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
class NotificationsPagingAdapter(
|
||||||
|
private val accountId: String,
|
||||||
|
private var statusDisplayOptions: StatusDisplayOptions,
|
||||||
|
private val statusListener: StatusActionListener,
|
||||||
|
private val notificationActionListener: NotificationActionListener,
|
||||||
|
private val accountActionListener: AccountActionListener
|
||||||
|
) : PagingDataAdapter<NotificationViewData, RecyclerView.ViewHolder>(NotificationsDifferCallback) {
|
||||||
|
|
||||||
|
var mediaPreviewEnabled: Boolean
|
||||||
|
get() = statusDisplayOptions.mediaPreviewEnabled
|
||||||
|
set(mediaPreviewEnabled) {
|
||||||
|
statusDisplayOptions = statusDisplayOptions.copy(
|
||||||
|
mediaPreviewEnabled = mediaPreviewEnabled
|
||||||
|
)
|
||||||
|
notifyItemRangeChanged(0, itemCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
private val absoluteTimeFormatter = AbsoluteTimeFormatter()
|
||||||
|
|
||||||
|
init {
|
||||||
|
stateRestorationPolicy = StateRestorationPolicy.PREVENT_WHEN_EMPTY
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getItemViewType(position: Int): Int {
|
||||||
|
return when (val notification = getItem(position)) {
|
||||||
|
is NotificationViewData.Concrete -> {
|
||||||
|
when (notification.type) {
|
||||||
|
Notification.Type.MENTION,
|
||||||
|
Notification.Type.POLL -> VIEW_TYPE_STATUS
|
||||||
|
Notification.Type.STATUS,
|
||||||
|
Notification.Type.FAVOURITE,
|
||||||
|
Notification.Type.REBLOG,
|
||||||
|
Notification.Type.UPDATE -> VIEW_TYPE_STATUS_NOTIFICATION
|
||||||
|
Notification.Type.FOLLOW,
|
||||||
|
Notification.Type.SIGN_UP -> VIEW_TYPE_FOLLOW
|
||||||
|
Notification.Type.FOLLOW_REQUEST -> VIEW_TYPE_FOLLOW_REQUEST
|
||||||
|
Notification.Type.REPORT -> VIEW_TYPE_REPORT
|
||||||
|
else -> VIEW_TYPE_UNKNOWN
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is NotificationViewData.Placeholder -> VIEW_TYPE_PLACEHOLDER
|
||||||
|
null -> throw IllegalStateException("no item at position $position")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
|
||||||
|
val inflater = LayoutInflater.from(parent.context)
|
||||||
|
return when (viewType) {
|
||||||
|
VIEW_TYPE_STATUS -> StatusViewHolder(
|
||||||
|
ItemStatusBinding.inflate(inflater, parent, false),
|
||||||
|
statusListener,
|
||||||
|
accountId
|
||||||
|
)
|
||||||
|
VIEW_TYPE_STATUS_NOTIFICATION -> StatusNotificationViewHolder(
|
||||||
|
ItemStatusNotificationBinding.inflate(inflater, parent, false),
|
||||||
|
statusListener,
|
||||||
|
absoluteTimeFormatter
|
||||||
|
)
|
||||||
|
VIEW_TYPE_FOLLOW -> FollowViewHolder(
|
||||||
|
ItemFollowBinding.inflate(inflater, parent, false),
|
||||||
|
accountActionListener
|
||||||
|
)
|
||||||
|
VIEW_TYPE_FOLLOW_REQUEST -> FollowRequestViewHolder(
|
||||||
|
ItemFollowRequestBinding.inflate(inflater, parent, false),
|
||||||
|
accountActionListener,
|
||||||
|
statusListener,
|
||||||
|
true
|
||||||
|
)
|
||||||
|
VIEW_TYPE_PLACEHOLDER -> PlaceholderViewHolder(
|
||||||
|
ItemStatusPlaceholderBinding.inflate(inflater, parent, false),
|
||||||
|
statusListener
|
||||||
|
)
|
||||||
|
VIEW_TYPE_REPORT -> ReportNotificationViewHolder(
|
||||||
|
ItemReportNotificationBinding.inflate(inflater, parent, false),
|
||||||
|
notificationActionListener,
|
||||||
|
accountActionListener
|
||||||
|
)
|
||||||
|
else -> UnknownNotificationViewHolder(
|
||||||
|
ItemUnknownNotificationBinding.inflate(inflater, parent, false)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onBindViewHolder(viewHolder: RecyclerView.ViewHolder, position: Int) {
|
||||||
|
bindViewHolder(viewHolder, position, emptyList())
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onBindViewHolder(
|
||||||
|
viewHolder: RecyclerView.ViewHolder,
|
||||||
|
position: Int,
|
||||||
|
payloads: List<Any>
|
||||||
|
) {
|
||||||
|
bindViewHolder(viewHolder, position, payloads)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun bindViewHolder(viewHolder: RecyclerView.ViewHolder, position: Int, payloads: List<Any>) {
|
||||||
|
getItem(position)?.let { notification ->
|
||||||
|
when (notification) {
|
||||||
|
is NotificationViewData.Concrete ->
|
||||||
|
(viewHolder as NotificationsViewHolder).bind(notification, payloads, statusDisplayOptions)
|
||||||
|
is NotificationViewData.Placeholder -> {
|
||||||
|
(viewHolder as PlaceholderViewHolder).setup(notification.isLoading)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val VIEW_TYPE_STATUS = 0
|
||||||
|
private const val VIEW_TYPE_STATUS_NOTIFICATION = 1
|
||||||
|
private const val VIEW_TYPE_FOLLOW = 2
|
||||||
|
private const val VIEW_TYPE_FOLLOW_REQUEST = 3
|
||||||
|
private const val VIEW_TYPE_PLACEHOLDER = 4
|
||||||
|
private const val VIEW_TYPE_REPORT = 5
|
||||||
|
private const val VIEW_TYPE_UNKNOWN = 6
|
||||||
|
|
||||||
|
val NotificationsDifferCallback = object : DiffUtil.ItemCallback<NotificationViewData>() {
|
||||||
|
override fun areItemsTheSame(
|
||||||
|
oldItem: NotificationViewData,
|
||||||
|
newItem: NotificationViewData
|
||||||
|
): Boolean {
|
||||||
|
return oldItem.id == newItem.id
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun areContentsTheSame(
|
||||||
|
oldItem: NotificationViewData,
|
||||||
|
newItem: NotificationViewData
|
||||||
|
): Boolean {
|
||||||
|
return false // Items are different always. It allows to refresh timestamp on every view holder update
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getChangePayload(
|
||||||
|
oldItem: NotificationViewData,
|
||||||
|
newItem: NotificationViewData
|
||||||
|
): Any? {
|
||||||
|
return if (oldItem == newItem) {
|
||||||
|
// If items are equal - update timestamp only
|
||||||
|
listOf(StatusBaseViewHolder.Key.KEY_CREATED)
|
||||||
|
} else {
|
||||||
|
// If items are different - update the whole view holder
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,208 @@
|
||||||
|
/* Copyright 2024 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.notifications
|
||||||
|
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.paging.ExperimentalPagingApi
|
||||||
|
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.toEntity
|
||||||
|
import com.keylesspalace.tusky.components.timeline.util.ifExpected
|
||||||
|
import com.keylesspalace.tusky.db.AccountManager
|
||||||
|
import com.keylesspalace.tusky.db.AppDatabase
|
||||||
|
import com.keylesspalace.tusky.db.entity.NotificationDataEntity
|
||||||
|
import com.keylesspalace.tusky.db.entity.TimelineStatusEntity
|
||||||
|
import com.keylesspalace.tusky.entity.Notification
|
||||||
|
import com.keylesspalace.tusky.network.MastodonApi
|
||||||
|
import com.keylesspalace.tusky.util.isLessThan
|
||||||
|
import retrofit2.HttpException
|
||||||
|
|
||||||
|
@OptIn(ExperimentalPagingApi::class)
|
||||||
|
class NotificationsRemoteMediator(
|
||||||
|
private val accountManager: AccountManager,
|
||||||
|
private val api: MastodonApi,
|
||||||
|
private val db: AppDatabase,
|
||||||
|
var excludes: Set<Notification.Type>
|
||||||
|
) : RemoteMediator<Int, NotificationDataEntity>() {
|
||||||
|
|
||||||
|
private var initialRefresh = false
|
||||||
|
|
||||||
|
private val notificationsDao = db.notificationsDao()
|
||||||
|
private val accountDao = db.timelineAccountDao()
|
||||||
|
private val statusDao = db.timelineStatusDao()
|
||||||
|
private val activeAccount = accountManager.activeAccount!!
|
||||||
|
|
||||||
|
override suspend fun load(
|
||||||
|
loadType: LoadType,
|
||||||
|
state: PagingState<Int, NotificationDataEntity>
|
||||||
|
): MediatorResult {
|
||||||
|
if (!activeAccount.isLoggedIn()) {
|
||||||
|
return MediatorResult.Success(endOfPaginationReached = true)
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
var dbEmpty = false
|
||||||
|
|
||||||
|
val topPlaceholderId = if (loadType == LoadType.REFRESH) {
|
||||||
|
notificationsDao.getTopPlaceholderId(activeAccount.id)
|
||||||
|
} else {
|
||||||
|
null // don't execute the query if it is not needed
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!initialRefresh && loadType == LoadType.REFRESH) {
|
||||||
|
val topId = notificationsDao.getTopId(activeAccount.id)
|
||||||
|
topId?.let { cachedTopId ->
|
||||||
|
val notificationResponse = api.notifications(
|
||||||
|
maxId = cachedTopId,
|
||||||
|
// so already existing placeholders don't get accidentally overwritten
|
||||||
|
sinceId = topPlaceholderId,
|
||||||
|
limit = state.config.pageSize,
|
||||||
|
excludes = excludes
|
||||||
|
)
|
||||||
|
|
||||||
|
val notifications = notificationResponse.body()
|
||||||
|
if (notificationResponse.isSuccessful && notifications != null) {
|
||||||
|
db.withTransaction {
|
||||||
|
replaceNotificationRange(notifications, state)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
initialRefresh = true
|
||||||
|
dbEmpty = topId == null
|
||||||
|
}
|
||||||
|
|
||||||
|
val notificationResponse = when (loadType) {
|
||||||
|
LoadType.REFRESH -> {
|
||||||
|
api.notifications(sinceId = topPlaceholderId, limit = state.config.pageSize, excludes = excludes)
|
||||||
|
}
|
||||||
|
LoadType.PREPEND -> {
|
||||||
|
return MediatorResult.Success(endOfPaginationReached = true)
|
||||||
|
}
|
||||||
|
LoadType.APPEND -> {
|
||||||
|
val maxId = state.pages.findLast { it.data.isNotEmpty() }?.data?.lastOrNull()?.id
|
||||||
|
api.notifications(maxId = maxId, limit = state.config.pageSize, excludes = excludes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val notifications = notificationResponse.body()
|
||||||
|
if (!notificationResponse.isSuccessful || notifications == null) {
|
||||||
|
return MediatorResult.Error(HttpException(notificationResponse))
|
||||||
|
}
|
||||||
|
|
||||||
|
db.withTransaction {
|
||||||
|
val overlappedNotifications = replaceNotificationRange(notifications, state)
|
||||||
|
|
||||||
|
/* In case we loaded a whole page and there was no overlap with existing statuses,
|
||||||
|
we insert a placeholder because there might be even more unknown statuses */
|
||||||
|
if (loadType == LoadType.REFRESH && overlappedNotifications == 0 && notifications.size == state.config.pageSize && !dbEmpty) {
|
||||||
|
/* This overrides the last of the newly loaded statuses with a placeholder
|
||||||
|
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)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return MediatorResult.Success(endOfPaginationReached = notifications.isEmpty())
|
||||||
|
} catch (e: Exception) {
|
||||||
|
return ifExpected(e) {
|
||||||
|
Log.w(TAG, "Failed to load notifications", e)
|
||||||
|
MediatorResult.Error(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deletes all notifications in a given range and inserts new notifications.
|
||||||
|
* This is necessary so notifications that have been deleted on the server are cleaned up.
|
||||||
|
* Should be run in a transaction as it executes multiple db updates
|
||||||
|
* @param notifications the new notifications
|
||||||
|
* @return the number of old notifications that have been cleared from the database
|
||||||
|
*/
|
||||||
|
private suspend fun replaceNotificationRange(notifications: List<Notification>, state: PagingState<Int, NotificationDataEntity>): Int {
|
||||||
|
val overlappedNotifications = if (notifications.isNotEmpty()) {
|
||||||
|
notificationsDao.deleteRange(activeAccount.id, notifications.last().id, notifications.first().id)
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
}
|
||||||
|
|
||||||
|
for (notification in notifications) {
|
||||||
|
accountDao.insert(notification.account.toEntity(activeAccount.id))
|
||||||
|
notification.report?.let { report ->
|
||||||
|
accountDao.insert(report.targetAccount.toEntity(activeAccount.id))
|
||||||
|
notificationsDao.insertReport(report.toEntity(activeAccount.id))
|
||||||
|
}
|
||||||
|
|
||||||
|
// check if we already have one of the newly loaded statuses cached locally
|
||||||
|
// in case we do, copy the local state (expanded, contentShowing, contentCollapsed) over so it doesn't get lost
|
||||||
|
var oldStatus: TimelineStatusEntity? = null
|
||||||
|
for (page in state.pages) {
|
||||||
|
oldStatus = page.data.find { s ->
|
||||||
|
s.id == notification.id
|
||||||
|
}?.status
|
||||||
|
if (oldStatus != null) break
|
||||||
|
}
|
||||||
|
|
||||||
|
notification.status?.let { status ->
|
||||||
|
val expanded = oldStatus?.expanded ?: activeAccount.alwaysOpenSpoiler
|
||||||
|
val contentShowing = oldStatus?.contentShowing ?: (activeAccount.alwaysShowSensitiveMedia || !status.sensitive)
|
||||||
|
val contentCollapsed = oldStatus?.contentCollapsed ?: true
|
||||||
|
|
||||||
|
accountDao.insert(status.account.toEntity(activeAccount.id))
|
||||||
|
|
||||||
|
statusDao.insert(
|
||||||
|
status.toEntity(
|
||||||
|
tuskyAccountId = activeAccount.id,
|
||||||
|
expanded = expanded,
|
||||||
|
contentShowing = contentShowing,
|
||||||
|
contentCollapsed = contentCollapsed
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
notificationsDao.insertNotification(
|
||||||
|
notification.toEntity(
|
||||||
|
activeAccount.id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
notifications.firstOrNull()?.let { notification ->
|
||||||
|
saveNewestNotificationId(notification)
|
||||||
|
}
|
||||||
|
return overlappedNotifications
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun saveNewestNotificationId(notification: Notification) {
|
||||||
|
val account = accountManager.activeAccount
|
||||||
|
// make sure the account we are currently working with is still active
|
||||||
|
if (account == activeAccount) {
|
||||||
|
val lastNotificationId: String = activeAccount.lastNotificationId
|
||||||
|
val newestNotificationId = notification.id
|
||||||
|
if (lastNotificationId.isLessThan(newestNotificationId)) {
|
||||||
|
Log.d(TAG, "saving newest noti id: $lastNotificationId for account ${account.id}")
|
||||||
|
account.lastNotificationId = newestNotificationId
|
||||||
|
accountManager.saveAccount(account)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "NotificationsRM"
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,406 @@
|
||||||
|
/* Copyright 2024 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.notifications
|
||||||
|
|
||||||
|
import android.content.SharedPreferences
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import androidx.paging.ExperimentalPagingApi
|
||||||
|
import androidx.paging.Pager
|
||||||
|
import androidx.paging.PagingConfig
|
||||||
|
import androidx.paging.cachedIn
|
||||||
|
import androidx.paging.filter
|
||||||
|
import androidx.paging.map
|
||||||
|
import androidx.room.withTransaction
|
||||||
|
import at.connyduck.calladapter.networkresult.NetworkResult
|
||||||
|
import at.connyduck.calladapter.networkresult.fold
|
||||||
|
import at.connyduck.calladapter.networkresult.map
|
||||||
|
import at.connyduck.calladapter.networkresult.onFailure
|
||||||
|
import com.keylesspalace.tusky.appstore.EventHub
|
||||||
|
import com.keylesspalace.tusky.appstore.PreferenceChangedEvent
|
||||||
|
import com.keylesspalace.tusky.components.preference.PreferencesFragment.ReadingOrder
|
||||||
|
import com.keylesspalace.tusky.components.timeline.Placeholder
|
||||||
|
import com.keylesspalace.tusky.components.timeline.toEntity
|
||||||
|
import com.keylesspalace.tusky.components.timeline.util.ifExpected
|
||||||
|
import com.keylesspalace.tusky.components.timeline.viewmodel.TimelineViewModel
|
||||||
|
import com.keylesspalace.tusky.db.AccountManager
|
||||||
|
import com.keylesspalace.tusky.db.AppDatabase
|
||||||
|
import com.keylesspalace.tusky.entity.Filter
|
||||||
|
import com.keylesspalace.tusky.entity.Notification
|
||||||
|
import com.keylesspalace.tusky.network.FilterModel
|
||||||
|
import com.keylesspalace.tusky.network.MastodonApi
|
||||||
|
import com.keylesspalace.tusky.settings.PrefKeys
|
||||||
|
import com.keylesspalace.tusky.usecase.TimelineCases
|
||||||
|
import com.keylesspalace.tusky.util.EmptyPagingSource
|
||||||
|
import com.keylesspalace.tusky.util.deserialize
|
||||||
|
import com.keylesspalace.tusky.util.serialize
|
||||||
|
import com.keylesspalace.tusky.viewdata.NotificationViewData
|
||||||
|
import com.keylesspalace.tusky.viewdata.StatusViewData
|
||||||
|
import com.keylesspalace.tusky.viewdata.TranslationViewData
|
||||||
|
import javax.inject.Inject
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.combine
|
||||||
|
import kotlinx.coroutines.flow.flatMapLatest
|
||||||
|
import kotlinx.coroutines.flow.flowOn
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import retrofit2.HttpException
|
||||||
|
|
||||||
|
class NotificationsViewModel @Inject constructor(
|
||||||
|
private val timelineCases: TimelineCases,
|
||||||
|
private val api: MastodonApi,
|
||||||
|
eventHub: EventHub,
|
||||||
|
private val accountManager: AccountManager,
|
||||||
|
private val preferences: SharedPreferences,
|
||||||
|
private val filterModel: FilterModel,
|
||||||
|
private val db: AppDatabase,
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
|
private val refreshTrigger = MutableStateFlow(0L)
|
||||||
|
|
||||||
|
private val _filters = MutableStateFlow(
|
||||||
|
accountManager.activeAccount?.let { account -> deserialize(account.notificationsFilter) } ?: emptySet()
|
||||||
|
)
|
||||||
|
val filters: StateFlow<Set<Notification.Type>> = _filters.asStateFlow()
|
||||||
|
|
||||||
|
/** Map from notification id to translation. */
|
||||||
|
private val translations = MutableStateFlow(mapOf<String, TranslationViewData>())
|
||||||
|
|
||||||
|
private var remoteMediator = NotificationsRemoteMediator(accountManager, api, db, filters.value)
|
||||||
|
|
||||||
|
private var readingOrder: ReadingOrder =
|
||||||
|
ReadingOrder.from(preferences.getString(PrefKeys.READING_ORDER, null))
|
||||||
|
|
||||||
|
@OptIn(ExperimentalPagingApi::class, ExperimentalCoroutinesApi::class)
|
||||||
|
val notifications = refreshTrigger.flatMapLatest {
|
||||||
|
Pager(
|
||||||
|
config = PagingConfig(pageSize = LOAD_AT_ONCE),
|
||||||
|
remoteMediator = remoteMediator,
|
||||||
|
pagingSourceFactory = {
|
||||||
|
val activeAccount = accountManager.activeAccount
|
||||||
|
if (activeAccount == null) {
|
||||||
|
EmptyPagingSource()
|
||||||
|
} else {
|
||||||
|
db.notificationsDao().getNotifications(activeAccount.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
).flow
|
||||||
|
.cachedIn(viewModelScope)
|
||||||
|
.combine(translations) { pagingData, translations ->
|
||||||
|
pagingData.map { notification ->
|
||||||
|
val translation = translations[notification.status?.serverId]
|
||||||
|
notification.toViewData(translation = translation)
|
||||||
|
}.filter { notificationViewData ->
|
||||||
|
shouldFilterStatus(notificationViewData) != Filter.Action.HIDE
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.flowOn(Dispatchers.Default)
|
||||||
|
|
||||||
|
init {
|
||||||
|
viewModelScope.launch {
|
||||||
|
eventHub.events.collect { event ->
|
||||||
|
if (event is PreferenceChangedEvent) {
|
||||||
|
onPreferenceChanged(event.preferenceKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateNotificationFilters(newFilters: Set<Notification.Type>) {
|
||||||
|
if (newFilters != _filters.value) {
|
||||||
|
val account = accountManager.activeAccount
|
||||||
|
if (account != null) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
account.notificationsFilter = serialize(newFilters)
|
||||||
|
accountManager.saveAccount(account)
|
||||||
|
remoteMediator.excludes = newFilters
|
||||||
|
// clear the cache to trigger a reload
|
||||||
|
db.notificationsDao().cleanupNotifications(account.id, 0)
|
||||||
|
_filters.value = newFilters
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun shouldFilterStatus(notificationViewData: NotificationViewData): Filter.Action {
|
||||||
|
return when ((notificationViewData as? NotificationViewData.Concrete)?.type) {
|
||||||
|
Notification.Type.MENTION, Notification.Type.STATUS, Notification.Type.POLL -> {
|
||||||
|
notificationViewData.statusViewData?.let { statusViewData ->
|
||||||
|
statusViewData.filterAction = filterModel.shouldFilterStatus(statusViewData.actionable)
|
||||||
|
return statusViewData.filterAction
|
||||||
|
}
|
||||||
|
Filter.Action.NONE
|
||||||
|
}
|
||||||
|
else -> Filter.Action.NONE
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun respondToFollowRequest(accept: Boolean, accountId: String, notificationId: String) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
if (accept) {
|
||||||
|
api.authorizeFollowRequest(accountId)
|
||||||
|
} else {
|
||||||
|
api.rejectFollowRequest(accountId)
|
||||||
|
}.fold(
|
||||||
|
onSuccess = {
|
||||||
|
// since the follow request has been responded, the notification can be deleted. The Ui will update automatically.
|
||||||
|
db.notificationsDao().delete(accountManager.activeAccount!!.id, notificationId)
|
||||||
|
if (accept) {
|
||||||
|
// refresh the notifications so the new follow notification will be loaded
|
||||||
|
refreshTrigger.value++
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onFailure = { t ->
|
||||||
|
Log.e(TAG, "Failed to to respond to follow request from account id $accountId.", t)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun reblog(reblog: Boolean, status: StatusViewData.Concrete): Job = viewModelScope.launch {
|
||||||
|
timelineCases.reblog(status.actionableId, reblog).onFailure { t ->
|
||||||
|
ifExpected(t) {
|
||||||
|
Log.w(TAG, "Failed to reblog status " + status.actionableId, t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun favorite(favorite: Boolean, status: StatusViewData.Concrete): Job = viewModelScope.launch {
|
||||||
|
timelineCases.favourite(status.actionableId, favorite).onFailure { t ->
|
||||||
|
ifExpected(t) {
|
||||||
|
Log.d(TAG, "Failed to favourite status " + status.actionableId, t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun bookmark(bookmark: Boolean, status: StatusViewData.Concrete): Job = viewModelScope.launch {
|
||||||
|
timelineCases.bookmark(status.actionableId, bookmark).onFailure { t ->
|
||||||
|
ifExpected(t) {
|
||||||
|
Log.d(TAG, "Failed to bookmark status " + status.actionableId, t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun voteInPoll(choices: List<Int>, status: StatusViewData.Concrete) = viewModelScope.launch {
|
||||||
|
val poll = status.status.actionableStatus.poll ?: run {
|
||||||
|
Log.d(TAG, "No poll on status ${status.id}")
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
timelineCases.voteInPoll(status.actionableId, poll.id, choices).onFailure { t ->
|
||||||
|
ifExpected(t) {
|
||||||
|
Log.d(TAG, "Failed to vote in poll: " + status.actionableId, t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun changeExpanded(expanded: Boolean, status: StatusViewData.Concrete) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
db.timelineStatusDao()
|
||||||
|
.setExpanded(accountManager.activeAccount!!.id, status.id, expanded)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun changeContentShowing(isShowing: Boolean, status: StatusViewData.Concrete) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
db.timelineStatusDao()
|
||||||
|
.setContentShowing(accountManager.activeAccount!!.id, status.id, isShowing)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun changeContentCollapsed(isCollapsed: Boolean, status: StatusViewData.Concrete) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
db.timelineStatusDao()
|
||||||
|
.setContentCollapsed(accountManager.activeAccount!!.id, status.id, isCollapsed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun remove(notificationId: String) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
db.notificationsDao().delete(accountManager.activeAccount!!.id, notificationId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clearWarning(status: StatusViewData.Concrete) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
db.timelineStatusDao().clearWarning(accountManager.activeAccount!!.id, status.actionableId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clearNotifications() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
api.clearNotifications().fold(
|
||||||
|
{
|
||||||
|
db.notificationsDao().cleanupNotifications(accountManager.activeAccount!!.id, 0)
|
||||||
|
},
|
||||||
|
{ t ->
|
||||||
|
Log.w(TAG, "failed to clear notifications", t)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun translate(status: StatusViewData.Concrete): NetworkResult<Unit> {
|
||||||
|
translations.value += (status.id to TranslationViewData.Loading)
|
||||||
|
return timelineCases.translate(status.actionableId)
|
||||||
|
.map { translation ->
|
||||||
|
translations.value += (status.id to TranslationViewData.Loaded(translation))
|
||||||
|
}
|
||||||
|
.onFailure {
|
||||||
|
translations.value -= status.id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun untranslate(status: StatusViewData.Concrete) {
|
||||||
|
translations.value -= status.id
|
||||||
|
}
|
||||||
|
|
||||||
|
fun loadMore(placeholderId: String) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
try {
|
||||||
|
val notificationsDao = db.notificationsDao()
|
||||||
|
|
||||||
|
val activeAccount = accountManager.activeAccount!!
|
||||||
|
|
||||||
|
notificationsDao.insertNotification(
|
||||||
|
Placeholder(placeholderId, loading = true).toNotificationEntity(
|
||||||
|
activeAccount.id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
val response = db.withTransaction {
|
||||||
|
val idAbovePlaceholder = notificationsDao.getIdAbove(activeAccount.id, placeholderId)
|
||||||
|
val idBelowPlaceholder = notificationsDao.getIdBelow(activeAccount.id, placeholderId)
|
||||||
|
when (readingOrder) {
|
||||||
|
// Using minId, loads up to LOAD_AT_ONCE statuses with IDs immediately
|
||||||
|
// after minId and no larger than maxId
|
||||||
|
ReadingOrder.OLDEST_FIRST -> api.notifications(
|
||||||
|
maxId = idAbovePlaceholder,
|
||||||
|
minId = idBelowPlaceholder,
|
||||||
|
limit = TimelineViewModel.LOAD_AT_ONCE
|
||||||
|
)
|
||||||
|
// Using sinceId, loads up to LOAD_AT_ONCE statuses immediately before
|
||||||
|
// maxId, and no smaller than minId.
|
||||||
|
ReadingOrder.NEWEST_FIRST -> api.notifications(
|
||||||
|
maxId = idAbovePlaceholder,
|
||||||
|
sinceId = idBelowPlaceholder,
|
||||||
|
limit = TimelineViewModel.LOAD_AT_ONCE
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val notifications = response.body()
|
||||||
|
if (!response.isSuccessful || notifications == null) {
|
||||||
|
loadMoreFailed(placeholderId, HttpException(response))
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
|
||||||
|
val statusDao = db.timelineStatusDao()
|
||||||
|
val accountDao = db.timelineAccountDao()
|
||||||
|
|
||||||
|
db.withTransaction {
|
||||||
|
notificationsDao.delete(activeAccount.id, placeholderId)
|
||||||
|
|
||||||
|
val overlappedNotifications = if (notifications.isNotEmpty()) {
|
||||||
|
notificationsDao.deleteRange(
|
||||||
|
activeAccount.id,
|
||||||
|
notifications.last().id,
|
||||||
|
notifications.first().id
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
}
|
||||||
|
|
||||||
|
for (notification in notifications) {
|
||||||
|
accountDao.insert(notification.account.toEntity(activeAccount.id))
|
||||||
|
notification.report?.let { report ->
|
||||||
|
accountDao.insert(report.targetAccount.toEntity(activeAccount.id))
|
||||||
|
notificationsDao.insertReport(report.toEntity(activeAccount.id))
|
||||||
|
}
|
||||||
|
notification.status?.let { status ->
|
||||||
|
accountDao.insert(status.account.toEntity(activeAccount.id))
|
||||||
|
|
||||||
|
statusDao.insert(
|
||||||
|
status.toEntity(
|
||||||
|
tuskyAccountId = activeAccount.id,
|
||||||
|
expanded = activeAccount.alwaysOpenSpoiler,
|
||||||
|
contentShowing = activeAccount.alwaysShowSensitiveMedia || !status.sensitive,
|
||||||
|
contentCollapsed = true
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
notificationsDao.insertNotification(
|
||||||
|
notification.toEntity(
|
||||||
|
activeAccount.id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/* In case we loaded a whole page and there was no overlap with existing notifications,
|
||||||
|
we insert a placeholder because there might be even more unknown notifications */
|
||||||
|
if (overlappedNotifications == 0 && notifications.size == TimelineViewModel.LOAD_AT_ONCE) {
|
||||||
|
/* This overrides the first/last of the newly loaded notifications with a placeholder
|
||||||
|
to guarantee the placeholder has an id that exists on the server as not all
|
||||||
|
servers handle client generated ids as expected */
|
||||||
|
val idToConvert = when (readingOrder) {
|
||||||
|
ReadingOrder.OLDEST_FIRST -> notifications.first().id
|
||||||
|
ReadingOrder.NEWEST_FIRST -> notifications.last().id
|
||||||
|
}
|
||||||
|
notificationsDao.insertNotification(
|
||||||
|
Placeholder(
|
||||||
|
idToConvert,
|
||||||
|
loading = false
|
||||||
|
).toNotificationEntity(activeAccount.id)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
ifExpected(e) {
|
||||||
|
loadMoreFailed(placeholderId, e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun loadMoreFailed(placeholderId: String, e: Exception) {
|
||||||
|
Log.w(TAG, "failed loading notifications", e)
|
||||||
|
val activeAccount = accountManager.activeAccount!!
|
||||||
|
db.notificationsDao()
|
||||||
|
.insertNotification(
|
||||||
|
Placeholder(placeholderId, loading = false).toNotificationEntity(activeAccount.id)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun onPreferenceChanged(key: String) {
|
||||||
|
when (key) {
|
||||||
|
PrefKeys.READING_ORDER -> {
|
||||||
|
readingOrder = ReadingOrder.from(
|
||||||
|
preferences.getString(PrefKeys.READING_ORDER, null)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val LOAD_AT_ONCE = 30
|
||||||
|
private const val TAG = "NotificationsViewModel"
|
||||||
|
}
|
||||||
|
}
|
|
@ -13,93 +13,75 @@
|
||||||
* You should have received a copy of the GNU General Public License along with Tusky; if not,
|
* You should have received a copy of the GNU General Public License along with Tusky; if not,
|
||||||
* see <http://www.gnu.org/licenses>. */
|
* see <http://www.gnu.org/licenses>. */
|
||||||
|
|
||||||
package com.keylesspalace.tusky.adapter
|
package com.keylesspalace.tusky.components.notifications
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import androidx.core.content.ContextCompat
|
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
import at.connyduck.sparkbutton.helpers.Utils
|
|
||||||
import com.keylesspalace.tusky.R
|
import com.keylesspalace.tusky.R
|
||||||
import com.keylesspalace.tusky.adapter.NotificationsAdapter.NotificationActionListener
|
|
||||||
import com.keylesspalace.tusky.databinding.ItemReportNotificationBinding
|
import com.keylesspalace.tusky.databinding.ItemReportNotificationBinding
|
||||||
import com.keylesspalace.tusky.entity.Report
|
import com.keylesspalace.tusky.interfaces.AccountActionListener
|
||||||
import com.keylesspalace.tusky.entity.TimelineAccount
|
import com.keylesspalace.tusky.util.StatusDisplayOptions
|
||||||
import com.keylesspalace.tusky.util.emojify
|
import com.keylesspalace.tusky.util.emojify
|
||||||
import com.keylesspalace.tusky.util.getRelativeTimeSpanString
|
import com.keylesspalace.tusky.util.getRelativeTimeSpanString
|
||||||
import com.keylesspalace.tusky.util.loadAvatar
|
import com.keylesspalace.tusky.util.loadAvatar
|
||||||
import com.keylesspalace.tusky.util.unicodeWrap
|
import com.keylesspalace.tusky.util.unicodeWrap
|
||||||
|
import com.keylesspalace.tusky.viewdata.NotificationViewData
|
||||||
|
|
||||||
class ReportNotificationViewHolder(
|
class ReportNotificationViewHolder(
|
||||||
private val binding: ItemReportNotificationBinding
|
private val binding: ItemReportNotificationBinding,
|
||||||
) : RecyclerView.ViewHolder(binding.root) {
|
private val listener: NotificationActionListener,
|
||||||
|
private val accountActionListener: AccountActionListener
|
||||||
|
) : RecyclerView.ViewHolder(binding.root), NotificationsViewHolder {
|
||||||
|
|
||||||
fun setupWithReport(
|
override fun bind(
|
||||||
reporter: TimelineAccount,
|
viewData: NotificationViewData.Concrete,
|
||||||
report: Report,
|
payloads: List<*>,
|
||||||
animateAvatar: Boolean,
|
statusDisplayOptions: StatusDisplayOptions
|
||||||
animateEmojis: Boolean
|
|
||||||
) {
|
) {
|
||||||
val reporterName = reporter.name.unicodeWrap().emojify(
|
val report = viewData.report!!
|
||||||
reporter.emojis,
|
val reporter = viewData.account
|
||||||
itemView,
|
|
||||||
animateEmojis
|
val reporterName = reporter.name.unicodeWrap().emojify(reporter.emojis, itemView, statusDisplayOptions.animateEmojis)
|
||||||
)
|
val reporteeName = report.targetAccount.name.unicodeWrap().emojify(report.targetAccount.emojis, itemView, statusDisplayOptions.animateEmojis)
|
||||||
val reporteeName = report.targetAccount.name.unicodeWrap().emojify(
|
|
||||||
report.targetAccount.emojis,
|
|
||||||
itemView,
|
|
||||||
animateEmojis
|
|
||||||
)
|
|
||||||
val icon = ContextCompat.getDrawable(itemView.context, R.drawable.ic_flag_24dp)
|
|
||||||
|
|
||||||
binding.notificationTopText.setCompoundDrawablesWithIntrinsicBounds(icon, null, null, null)
|
|
||||||
binding.notificationTopText.text = itemView.context.getString(R.string.notification_header_report_format, reporterName, reporteeName)
|
binding.notificationTopText.text = itemView.context.getString(R.string.notification_header_report_format, reporterName, reporteeName)
|
||||||
binding.notificationSummary.text = itemView.context.getString(R.string.notification_summary_report_format, getRelativeTimeSpanString(itemView.context, report.createdAt.time, System.currentTimeMillis()), report.statusIds?.size ?: 0)
|
binding.notificationSummary.text = itemView.context.getString(R.string.notification_summary_report_format, getRelativeTimeSpanString(itemView.context, report.createdAt.time, System.currentTimeMillis()), report.statusIds?.size ?: 0)
|
||||||
binding.notificationCategory.text = getTranslatedCategory(itemView.context, report.category)
|
binding.notificationCategory.text = getTranslatedCategory(itemView.context, report.category)
|
||||||
|
|
||||||
// Fancy avatar inset
|
|
||||||
val padding = Utils.dpToPx(binding.notificationReporteeAvatar.context, 12)
|
|
||||||
binding.notificationReporteeAvatar.setPaddingRelative(0, 0, padding, padding)
|
|
||||||
|
|
||||||
loadAvatar(
|
loadAvatar(
|
||||||
report.targetAccount.avatar,
|
report.targetAccount.avatar,
|
||||||
binding.notificationReporteeAvatar,
|
binding.notificationReporteeAvatar,
|
||||||
itemView.context.resources.getDimensionPixelSize(R.dimen.avatar_radius_36dp),
|
itemView.context.resources.getDimensionPixelSize(R.dimen.avatar_radius_36dp),
|
||||||
animateAvatar
|
statusDisplayOptions.animateAvatars,
|
||||||
)
|
)
|
||||||
loadAvatar(
|
loadAvatar(
|
||||||
reporter.avatar,
|
reporter.avatar,
|
||||||
binding.notificationReporterAvatar,
|
binding.notificationReporterAvatar,
|
||||||
itemView.context.resources.getDimensionPixelSize(R.dimen.avatar_radius_24dp),
|
itemView.context.resources.getDimensionPixelSize(R.dimen.avatar_radius_24dp),
|
||||||
animateAvatar
|
statusDisplayOptions.animateAvatars,
|
||||||
)
|
)
|
||||||
}
|
|
||||||
|
|
||||||
fun setupActionListener(
|
|
||||||
listener: NotificationActionListener,
|
|
||||||
reporteeId: String,
|
|
||||||
reporterId: String,
|
|
||||||
reportId: String
|
|
||||||
) {
|
|
||||||
binding.notificationReporteeAvatar.setOnClickListener {
|
binding.notificationReporteeAvatar.setOnClickListener {
|
||||||
val position = bindingAdapterPosition
|
val position = bindingAdapterPosition
|
||||||
if (position != RecyclerView.NO_POSITION) {
|
if (position != RecyclerView.NO_POSITION) {
|
||||||
listener.onViewAccount(reporteeId)
|
accountActionListener.onViewAccount(report.targetAccount.id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
binding.notificationReporterAvatar.setOnClickListener {
|
binding.notificationReporterAvatar.setOnClickListener {
|
||||||
val position = bindingAdapterPosition
|
val position = bindingAdapterPosition
|
||||||
if (position != RecyclerView.NO_POSITION) {
|
if (position != RecyclerView.NO_POSITION) {
|
||||||
listener.onViewAccount(reporterId)
|
accountActionListener.onViewAccount(reporter.id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
itemView.setOnClickListener { listener.onViewReport(reportId) }
|
itemView.setOnClickListener { listener.onViewReport(report.id) }
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun getTranslatedCategory(context: Context, rawCategory: String): String {
|
private fun getTranslatedCategory(context: Context, rawCategory: String): String {
|
||||||
return when (rawCategory) {
|
return when (rawCategory) {
|
||||||
"violation" -> context.getString(R.string.report_category_violation)
|
"violation" -> context.getString(R.string.report_category_violation)
|
||||||
"spam" -> context.getString(R.string.report_category_spam)
|
"spam" -> context.getString(R.string.report_category_spam)
|
||||||
|
"legal" -> context.getString(R.string.report_category_legal)
|
||||||
"other" -> context.getString(R.string.report_category_other)
|
"other" -> context.getString(R.string.report_category_other)
|
||||||
else -> rawCategory
|
else -> rawCategory
|
||||||
}
|
}
|
|
@ -0,0 +1,364 @@
|
||||||
|
/*
|
||||||
|
* Copyright 2023 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.notifications
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.graphics.PorterDuff
|
||||||
|
import android.graphics.Typeface
|
||||||
|
import android.graphics.drawable.Drawable
|
||||||
|
import android.text.InputFilter
|
||||||
|
import android.text.Spanned
|
||||||
|
import android.text.format.DateUtils
|
||||||
|
import android.text.style.StyleSpan
|
||||||
|
import android.view.View
|
||||||
|
import androidx.annotation.ColorRes
|
||||||
|
import androidx.annotation.DrawableRes
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import androidx.core.text.toSpannable
|
||||||
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
|
import at.connyduck.sparkbutton.helpers.Utils
|
||||||
|
import com.bumptech.glide.Glide
|
||||||
|
import com.keylesspalace.tusky.R
|
||||||
|
import com.keylesspalace.tusky.adapter.StatusBaseViewHolder
|
||||||
|
import com.keylesspalace.tusky.databinding.ItemStatusNotificationBinding
|
||||||
|
import com.keylesspalace.tusky.entity.Emoji
|
||||||
|
import com.keylesspalace.tusky.entity.Notification
|
||||||
|
import com.keylesspalace.tusky.interfaces.LinkListener
|
||||||
|
import com.keylesspalace.tusky.interfaces.StatusActionListener
|
||||||
|
import com.keylesspalace.tusky.util.AbsoluteTimeFormatter
|
||||||
|
import com.keylesspalace.tusky.util.SmartLengthInputFilter
|
||||||
|
import com.keylesspalace.tusky.util.StatusDisplayOptions
|
||||||
|
import com.keylesspalace.tusky.util.emojify
|
||||||
|
import com.keylesspalace.tusky.util.getRelativeTimeSpanString
|
||||||
|
import com.keylesspalace.tusky.util.loadAvatar
|
||||||
|
import com.keylesspalace.tusky.util.setClickableText
|
||||||
|
import com.keylesspalace.tusky.util.unicodeWrap
|
||||||
|
import com.keylesspalace.tusky.util.visible
|
||||||
|
import com.keylesspalace.tusky.viewdata.NotificationViewData
|
||||||
|
import com.keylesspalace.tusky.viewdata.StatusViewData
|
||||||
|
import java.util.Date
|
||||||
|
|
||||||
|
internal class StatusNotificationViewHolder(
|
||||||
|
private val binding: ItemStatusNotificationBinding,
|
||||||
|
private val statusActionListener: StatusActionListener,
|
||||||
|
private val absoluteTimeFormatter: AbsoluteTimeFormatter
|
||||||
|
) : NotificationsViewHolder, RecyclerView.ViewHolder(binding.root) {
|
||||||
|
private val avatarRadius48dp = itemView.context.resources.getDimensionPixelSize(
|
||||||
|
R.dimen.avatar_radius_48dp
|
||||||
|
)
|
||||||
|
private val avatarRadius36dp = itemView.context.resources.getDimensionPixelSize(
|
||||||
|
R.dimen.avatar_radius_36dp
|
||||||
|
)
|
||||||
|
private val avatarRadius24dp = itemView.context.resources.getDimensionPixelSize(
|
||||||
|
R.dimen.avatar_radius_24dp
|
||||||
|
)
|
||||||
|
|
||||||
|
override fun bind(
|
||||||
|
viewData: NotificationViewData.Concrete,
|
||||||
|
payloads: List<*>,
|
||||||
|
statusDisplayOptions: StatusDisplayOptions
|
||||||
|
) {
|
||||||
|
val statusViewData = viewData.statusViewData
|
||||||
|
if (payloads.isEmpty()) {
|
||||||
|
/* in some very rare cases servers sends null status even though they should not */
|
||||||
|
if (statusViewData == null) {
|
||||||
|
showNotificationContent(false)
|
||||||
|
} else {
|
||||||
|
showNotificationContent(true)
|
||||||
|
val (_, _, account, _, _, _, _, createdAt) = statusViewData.actionable
|
||||||
|
setDisplayName(account.name, account.emojis, statusDisplayOptions.animateEmojis)
|
||||||
|
setUsername(account.username)
|
||||||
|
setCreatedAt(createdAt, statusDisplayOptions.useAbsoluteTime)
|
||||||
|
if (viewData.type == Notification.Type.STATUS ||
|
||||||
|
viewData.type == Notification.Type.UPDATE
|
||||||
|
) {
|
||||||
|
setAvatar(
|
||||||
|
account.avatar,
|
||||||
|
account.bot,
|
||||||
|
statusDisplayOptions.animateAvatars,
|
||||||
|
statusDisplayOptions.showBotOverlay
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
setAvatars(
|
||||||
|
account.avatar,
|
||||||
|
viewData.account.avatar,
|
||||||
|
statusDisplayOptions.animateAvatars
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
binding.notificationContainer.setOnClickListener {
|
||||||
|
statusActionListener.onViewThread(bindingAdapterPosition)
|
||||||
|
}
|
||||||
|
binding.notificationContent.setOnClickListener {
|
||||||
|
statusActionListener.onViewThread(bindingAdapterPosition)
|
||||||
|
}
|
||||||
|
binding.notificationTopText.setOnClickListener {
|
||||||
|
statusActionListener.onViewAccount(viewData.account.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setMessage(viewData, statusActionListener, statusDisplayOptions.animateEmojis)
|
||||||
|
} else {
|
||||||
|
for (item in payloads) {
|
||||||
|
if (StatusBaseViewHolder.Key.KEY_CREATED == item && statusViewData != null) {
|
||||||
|
setCreatedAt(
|
||||||
|
statusViewData.status.actionableStatus.createdAt,
|
||||||
|
statusDisplayOptions.useAbsoluteTime
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun showNotificationContent(show: Boolean) {
|
||||||
|
binding.statusDisplayName.visible(show)
|
||||||
|
binding.statusUsername.visible(show)
|
||||||
|
binding.statusMetaInfo.visible(show)
|
||||||
|
binding.notificationContentWarningDescription.visible(show)
|
||||||
|
binding.notificationContentWarningButton.visible(show)
|
||||||
|
binding.notificationContent.visible(show)
|
||||||
|
binding.notificationStatusAvatar.visible(show)
|
||||||
|
binding.notificationNotificationAvatar.visible(show)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun setDisplayName(name: String, emojis: List<Emoji>, animateEmojis: Boolean) {
|
||||||
|
val emojifiedName = name.emojify(emojis, binding.statusDisplayName, animateEmojis)
|
||||||
|
binding.statusDisplayName.text = emojifiedName
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun setUsername(name: String) {
|
||||||
|
val context = binding.statusUsername.context
|
||||||
|
val format = context.getString(R.string.post_username_format)
|
||||||
|
val usernameText = String.format(format, name)
|
||||||
|
binding.statusUsername.text = usernameText
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun setCreatedAt(createdAt: Date, useAbsoluteTime: Boolean) {
|
||||||
|
if (useAbsoluteTime) {
|
||||||
|
binding.statusMetaInfo.text = absoluteTimeFormatter.format(createdAt, true)
|
||||||
|
} else {
|
||||||
|
val readout: String // visible timestamp
|
||||||
|
val readoutAloud: CharSequence // for screenreaders so they don't mispronounce timestamps like "17m" as 17 meters
|
||||||
|
|
||||||
|
val then = createdAt.time
|
||||||
|
val now = System.currentTimeMillis()
|
||||||
|
readout = getRelativeTimeSpanString(binding.statusMetaInfo.context, then, now)
|
||||||
|
readoutAloud = DateUtils.getRelativeTimeSpanString(
|
||||||
|
then,
|
||||||
|
now,
|
||||||
|
DateUtils.SECOND_IN_MILLIS,
|
||||||
|
DateUtils.FORMAT_ABBREV_RELATIVE
|
||||||
|
)
|
||||||
|
|
||||||
|
binding.statusMetaInfo.text = readout
|
||||||
|
binding.statusMetaInfo.contentDescription = readoutAloud
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getIconWithColor(
|
||||||
|
context: Context,
|
||||||
|
@DrawableRes drawable: Int,
|
||||||
|
@ColorRes color: Int
|
||||||
|
): Drawable? {
|
||||||
|
val icon = ContextCompat.getDrawable(context, drawable)
|
||||||
|
icon?.setColorFilter(context.getColor(color), PorterDuff.Mode.SRC_ATOP)
|
||||||
|
return icon
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun setAvatar(statusAvatarUrl: String?, isBot: Boolean, animateAvatars: Boolean, showBotOverlay: Boolean) {
|
||||||
|
binding.notificationStatusAvatar.setPaddingRelative(0, 0, 0, 0)
|
||||||
|
loadAvatar(
|
||||||
|
statusAvatarUrl,
|
||||||
|
binding.notificationStatusAvatar,
|
||||||
|
avatarRadius48dp,
|
||||||
|
animateAvatars
|
||||||
|
)
|
||||||
|
if (showBotOverlay && isBot) {
|
||||||
|
binding.notificationNotificationAvatar.visibility = View.VISIBLE
|
||||||
|
Glide.with(binding.notificationNotificationAvatar)
|
||||||
|
.load(R.drawable.bot_badge)
|
||||||
|
.into(binding.notificationNotificationAvatar)
|
||||||
|
} else {
|
||||||
|
binding.notificationNotificationAvatar.visibility = View.GONE
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun setAvatars(statusAvatarUrl: String?, notificationAvatarUrl: String?, animateAvatars: Boolean) {
|
||||||
|
val padding = Utils.dpToPx(binding.notificationStatusAvatar.context, 12)
|
||||||
|
binding.notificationStatusAvatar.setPaddingRelative(0, 0, padding, padding)
|
||||||
|
loadAvatar(
|
||||||
|
statusAvatarUrl,
|
||||||
|
binding.notificationStatusAvatar,
|
||||||
|
avatarRadius36dp,
|
||||||
|
animateAvatars
|
||||||
|
)
|
||||||
|
binding.notificationNotificationAvatar.visibility = View.VISIBLE
|
||||||
|
loadAvatar(
|
||||||
|
notificationAvatarUrl,
|
||||||
|
binding.notificationNotificationAvatar,
|
||||||
|
avatarRadius24dp,
|
||||||
|
animateAvatars
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setMessage(
|
||||||
|
notificationViewData: NotificationViewData.Concrete,
|
||||||
|
listener: LinkListener,
|
||||||
|
animateEmojis: Boolean
|
||||||
|
) {
|
||||||
|
val statusViewData = notificationViewData.statusViewData
|
||||||
|
val displayName = notificationViewData.account.name.unicodeWrap()
|
||||||
|
val type = notificationViewData.type
|
||||||
|
val context = binding.notificationTopText.context
|
||||||
|
val format: String
|
||||||
|
val icon: Drawable?
|
||||||
|
when (type) {
|
||||||
|
Notification.Type.FAVOURITE -> {
|
||||||
|
icon = getIconWithColor(context, R.drawable.ic_star_24dp, R.color.tusky_orange)
|
||||||
|
format = context.getString(R.string.notification_favourite_format)
|
||||||
|
}
|
||||||
|
Notification.Type.REBLOG -> {
|
||||||
|
icon = getIconWithColor(context, R.drawable.ic_repeat_24dp, R.color.tusky_blue)
|
||||||
|
format = context.getString(R.string.notification_reblog_format)
|
||||||
|
}
|
||||||
|
Notification.Type.STATUS -> {
|
||||||
|
icon = getIconWithColor(context, R.drawable.ic_home_24dp, R.color.tusky_blue)
|
||||||
|
format = context.getString(R.string.notification_subscription_format)
|
||||||
|
}
|
||||||
|
Notification.Type.UPDATE -> {
|
||||||
|
icon = getIconWithColor(context, R.drawable.ic_edit_24dp, R.color.tusky_blue)
|
||||||
|
format = context.getString(R.string.notification_update_format)
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
icon = getIconWithColor(context, R.drawable.ic_star_24dp, R.color.tusky_orange)
|
||||||
|
format = context.getString(R.string.notification_favourite_format)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
binding.notificationTopText.setCompoundDrawablesWithIntrinsicBounds(
|
||||||
|
icon,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null
|
||||||
|
)
|
||||||
|
val wholeMessage = String.format(format, displayName).toSpannable()
|
||||||
|
val displayNameIndex = format.indexOf("%1\$s")
|
||||||
|
wholeMessage.setSpan(
|
||||||
|
StyleSpan(Typeface.BOLD),
|
||||||
|
displayNameIndex,
|
||||||
|
displayNameIndex + displayName.length,
|
||||||
|
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
|
||||||
|
)
|
||||||
|
val emojifiedText = wholeMessage.emojify(
|
||||||
|
notificationViewData.account.emojis,
|
||||||
|
binding.notificationTopText,
|
||||||
|
animateEmojis
|
||||||
|
)
|
||||||
|
binding.notificationTopText.text = emojifiedText
|
||||||
|
if (statusViewData != null) {
|
||||||
|
val hasSpoiler = statusViewData.status.spoilerText.isNotEmpty()
|
||||||
|
binding.notificationContentWarningDescription.visibility =
|
||||||
|
if (hasSpoiler) View.VISIBLE else View.GONE
|
||||||
|
binding.notificationContentWarningButton.visibility =
|
||||||
|
if (hasSpoiler) View.VISIBLE else View.GONE
|
||||||
|
if (statusViewData.isExpanded) {
|
||||||
|
binding.notificationContentWarningButton.setText(
|
||||||
|
R.string.post_content_warning_show_less
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
binding.notificationContentWarningButton.setText(
|
||||||
|
R.string.post_content_warning_show_more
|
||||||
|
)
|
||||||
|
}
|
||||||
|
binding.notificationContentWarningButton.setOnClickListener {
|
||||||
|
if (bindingAdapterPosition != RecyclerView.NO_POSITION) {
|
||||||
|
statusActionListener.onExpandedChange(
|
||||||
|
!statusViewData.isExpanded,
|
||||||
|
bindingAdapterPosition
|
||||||
|
)
|
||||||
|
}
|
||||||
|
binding.notificationContent.visibility =
|
||||||
|
if (statusViewData.isExpanded) View.GONE else View.VISIBLE
|
||||||
|
}
|
||||||
|
setupContentAndSpoiler(listener, statusViewData, animateEmojis)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun setupContentAndSpoiler(
|
||||||
|
listener: LinkListener,
|
||||||
|
statusViewData: StatusViewData.Concrete,
|
||||||
|
animateEmojis: Boolean
|
||||||
|
) {
|
||||||
|
val shouldShowContentIfSpoiler = statusViewData.isExpanded
|
||||||
|
val hasSpoiler = statusViewData.status.spoilerText.isNotEmpty()
|
||||||
|
if (!shouldShowContentIfSpoiler && hasSpoiler) {
|
||||||
|
binding.notificationContent.visibility = View.GONE
|
||||||
|
} else {
|
||||||
|
binding.notificationContent.visibility = View.VISIBLE
|
||||||
|
}
|
||||||
|
val content = statusViewData.content
|
||||||
|
val emojis = statusViewData.actionable.emojis
|
||||||
|
if (statusViewData.isCollapsible && (statusViewData.isExpanded || !hasSpoiler)) {
|
||||||
|
binding.buttonToggleNotificationContent.setOnClickListener {
|
||||||
|
val position = bindingAdapterPosition
|
||||||
|
if (position != RecyclerView.NO_POSITION) {
|
||||||
|
statusActionListener.onContentCollapsedChange(
|
||||||
|
!statusViewData.isCollapsed,
|
||||||
|
position
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
binding.buttonToggleNotificationContent.visibility = View.VISIBLE
|
||||||
|
if (statusViewData.isCollapsed) {
|
||||||
|
binding.buttonToggleNotificationContent.setText(
|
||||||
|
R.string.post_content_warning_show_more
|
||||||
|
)
|
||||||
|
binding.notificationContent.filters = COLLAPSE_INPUT_FILTER
|
||||||
|
} else {
|
||||||
|
binding.buttonToggleNotificationContent.setText(
|
||||||
|
R.string.post_content_warning_show_less
|
||||||
|
)
|
||||||
|
binding.notificationContent.filters = NO_INPUT_FILTER
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
binding.buttonToggleNotificationContent.visibility = View.GONE
|
||||||
|
binding.notificationContent.filters = NO_INPUT_FILTER
|
||||||
|
}
|
||||||
|
val emojifiedText = content.emojify(
|
||||||
|
emojis = emojis,
|
||||||
|
view = binding.notificationContent,
|
||||||
|
animate = animateEmojis
|
||||||
|
)
|
||||||
|
setClickableText(
|
||||||
|
binding.notificationContent,
|
||||||
|
emojifiedText,
|
||||||
|
statusViewData.actionable.mentions,
|
||||||
|
statusViewData.actionable.tags,
|
||||||
|
listener
|
||||||
|
)
|
||||||
|
val emojifiedContentWarning: CharSequence = statusViewData.status.spoilerText.emojify(
|
||||||
|
statusViewData.actionable.emojis,
|
||||||
|
binding.notificationContentWarningDescription,
|
||||||
|
animateEmojis
|
||||||
|
)
|
||||||
|
binding.notificationContentWarningDescription.text = emojifiedContentWarning
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private val COLLAPSE_INPUT_FILTER: Array<InputFilter> = arrayOf(SmartLengthInputFilter)
|
||||||
|
private val NO_INPUT_FILTER: Array<InputFilter> = arrayOf()
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,59 @@
|
||||||
|
/*
|
||||||
|
* Copyright 2023 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.notifications
|
||||||
|
|
||||||
|
import com.keylesspalace.tusky.adapter.StatusViewHolder
|
||||||
|
import com.keylesspalace.tusky.databinding.ItemStatusBinding
|
||||||
|
import com.keylesspalace.tusky.entity.Notification
|
||||||
|
import com.keylesspalace.tusky.interfaces.StatusActionListener
|
||||||
|
import com.keylesspalace.tusky.util.StatusDisplayOptions
|
||||||
|
import com.keylesspalace.tusky.viewdata.NotificationViewData
|
||||||
|
|
||||||
|
internal class StatusViewHolder(
|
||||||
|
binding: ItemStatusBinding,
|
||||||
|
private val statusActionListener: StatusActionListener,
|
||||||
|
private val accountId: String
|
||||||
|
) : NotificationsViewHolder, StatusViewHolder(binding.root) {
|
||||||
|
|
||||||
|
override fun bind(
|
||||||
|
viewData: NotificationViewData.Concrete,
|
||||||
|
payloads: List<*>,
|
||||||
|
statusDisplayOptions: StatusDisplayOptions
|
||||||
|
) {
|
||||||
|
val statusViewData = viewData.statusViewData
|
||||||
|
if (statusViewData == null) {
|
||||||
|
/* in some very rare cases servers sends null status even though they should not */
|
||||||
|
showStatusContent(false)
|
||||||
|
} else {
|
||||||
|
if (payloads.isEmpty()) {
|
||||||
|
showStatusContent(true)
|
||||||
|
}
|
||||||
|
setupWithStatus(
|
||||||
|
statusViewData,
|
||||||
|
statusActionListener,
|
||||||
|
statusDisplayOptions,
|
||||||
|
payloads.firstOrNull()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (viewData.type == Notification.Type.POLL) {
|
||||||
|
setPollInfo(accountId == viewData.account.id)
|
||||||
|
} else {
|
||||||
|
hideStatusInfo()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,36 @@
|
||||||
|
/*
|
||||||
|
* Copyright 2023 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.notifications
|
||||||
|
|
||||||
|
import com.keylesspalace.tusky.adapter.StatusViewHolder
|
||||||
|
import com.keylesspalace.tusky.databinding.ItemUnknownNotificationBinding
|
||||||
|
import com.keylesspalace.tusky.util.StatusDisplayOptions
|
||||||
|
import com.keylesspalace.tusky.viewdata.NotificationViewData
|
||||||
|
|
||||||
|
internal class UnknownNotificationViewHolder(
|
||||||
|
binding: ItemUnknownNotificationBinding,
|
||||||
|
) : NotificationsViewHolder, StatusViewHolder(binding.root) {
|
||||||
|
|
||||||
|
override fun bind(
|
||||||
|
viewData: NotificationViewData.Concrete,
|
||||||
|
payloads: List<*>,
|
||||||
|
statusDisplayOptions: StatusDisplayOptions
|
||||||
|
) {
|
||||||
|
// nothing to do
|
||||||
|
}
|
||||||
|
}
|
|
@ -36,7 +36,7 @@ import com.keylesspalace.tusky.components.domainblocks.DomainBlocksActivity
|
||||||
import com.keylesspalace.tusky.components.filters.FiltersActivity
|
import com.keylesspalace.tusky.components.filters.FiltersActivity
|
||||||
import com.keylesspalace.tusky.components.followedtags.FollowedTagsActivity
|
import com.keylesspalace.tusky.components.followedtags.FollowedTagsActivity
|
||||||
import com.keylesspalace.tusky.components.login.LoginActivity
|
import com.keylesspalace.tusky.components.login.LoginActivity
|
||||||
import com.keylesspalace.tusky.components.notifications.currentAccountNeedsMigration
|
import com.keylesspalace.tusky.components.systemnotifications.currentAccountNeedsMigration
|
||||||
import com.keylesspalace.tusky.db.AccountManager
|
import com.keylesspalace.tusky.db.AccountManager
|
||||||
import com.keylesspalace.tusky.di.Injectable
|
import com.keylesspalace.tusky.di.Injectable
|
||||||
import com.keylesspalace.tusky.entity.Account
|
import com.keylesspalace.tusky.entity.Account
|
||||||
|
|
|
@ -18,9 +18,9 @@ package com.keylesspalace.tusky.components.preference
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import androidx.preference.PreferenceFragmentCompat
|
import androidx.preference.PreferenceFragmentCompat
|
||||||
import com.keylesspalace.tusky.R
|
import com.keylesspalace.tusky.R
|
||||||
import com.keylesspalace.tusky.components.notifications.NotificationHelper
|
import com.keylesspalace.tusky.components.systemnotifications.NotificationHelper
|
||||||
import com.keylesspalace.tusky.db.AccountEntity
|
|
||||||
import com.keylesspalace.tusky.db.AccountManager
|
import com.keylesspalace.tusky.db.AccountManager
|
||||||
|
import com.keylesspalace.tusky.db.entity.AccountEntity
|
||||||
import com.keylesspalace.tusky.di.Injectable
|
import com.keylesspalace.tusky.di.Injectable
|
||||||
import com.keylesspalace.tusky.settings.PrefKeys
|
import com.keylesspalace.tusky.settings.PrefKeys
|
||||||
import com.keylesspalace.tusky.settings.makePreferenceScreen
|
import com.keylesspalace.tusky.settings.makePreferenceScreen
|
||||||
|
|
|
@ -162,6 +162,13 @@ class PreferencesFragment : PreferenceFragmentCompat(), Injectable {
|
||||||
setTitle(R.string.pref_title_hide_top_toolbar)
|
setTitle(R.string.pref_title_hide_top_toolbar)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
switchPreference {
|
||||||
|
setDefaultValue(true)
|
||||||
|
key = PrefKeys.SHOW_NOTIFICATIONS_FILTER
|
||||||
|
setTitle(R.string.pref_title_show_notifications_filter)
|
||||||
|
isSingleLineTitle = false
|
||||||
|
}
|
||||||
|
|
||||||
switchPreference {
|
switchPreference {
|
||||||
setDefaultValue(false)
|
setDefaultValue(false)
|
||||||
key = PrefKeys.FAB_HIDE
|
key = PrefKeys.FAB_HIDE
|
||||||
|
|
|
@ -27,8 +27,8 @@ import at.connyduck.calladapter.networkresult.map
|
||||||
import at.connyduck.calladapter.networkresult.onFailure
|
import at.connyduck.calladapter.networkresult.onFailure
|
||||||
import com.keylesspalace.tusky.components.instanceinfo.InstanceInfoRepository
|
import com.keylesspalace.tusky.components.instanceinfo.InstanceInfoRepository
|
||||||
import com.keylesspalace.tusky.components.search.adapter.SearchPagingSourceFactory
|
import com.keylesspalace.tusky.components.search.adapter.SearchPagingSourceFactory
|
||||||
import com.keylesspalace.tusky.db.AccountEntity
|
|
||||||
import com.keylesspalace.tusky.db.AccountManager
|
import com.keylesspalace.tusky.db.AccountManager
|
||||||
|
import com.keylesspalace.tusky.db.entity.AccountEntity
|
||||||
import com.keylesspalace.tusky.entity.DeletedStatus
|
import com.keylesspalace.tusky.entity.DeletedStatus
|
||||||
import com.keylesspalace.tusky.entity.Status
|
import com.keylesspalace.tusky.entity.Status
|
||||||
import com.keylesspalace.tusky.network.MastodonApi
|
import com.keylesspalace.tusky.network.MastodonApi
|
||||||
|
|
|
@ -48,8 +48,8 @@ import com.keylesspalace.tusky.components.compose.ComposeActivity
|
||||||
import com.keylesspalace.tusky.components.compose.ComposeActivity.ComposeOptions
|
import com.keylesspalace.tusky.components.compose.ComposeActivity.ComposeOptions
|
||||||
import com.keylesspalace.tusky.components.report.ReportActivity
|
import com.keylesspalace.tusky.components.report.ReportActivity
|
||||||
import com.keylesspalace.tusky.components.search.adapter.SearchStatusesAdapter
|
import com.keylesspalace.tusky.components.search.adapter.SearchStatusesAdapter
|
||||||
import com.keylesspalace.tusky.db.AccountEntity
|
|
||||||
import com.keylesspalace.tusky.db.AccountManager
|
import com.keylesspalace.tusky.db.AccountManager
|
||||||
|
import com.keylesspalace.tusky.db.entity.AccountEntity
|
||||||
import com.keylesspalace.tusky.entity.Attachment
|
import com.keylesspalace.tusky.entity.Attachment
|
||||||
import com.keylesspalace.tusky.entity.Status
|
import com.keylesspalace.tusky.entity.Status
|
||||||
import com.keylesspalace.tusky.entity.Status.Mention
|
import com.keylesspalace.tusky.entity.Status.Mention
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
package com.keylesspalace.tusky.components.notifications
|
package com.keylesspalace.tusky.components.systemnotifications
|
||||||
|
|
||||||
import android.app.NotificationManager
|
import android.app.NotificationManager
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
@ -6,9 +6,9 @@ import android.util.Log
|
||||||
import androidx.annotation.WorkerThread
|
import androidx.annotation.WorkerThread
|
||||||
import com.keylesspalace.tusky.appstore.EventHub
|
import com.keylesspalace.tusky.appstore.EventHub
|
||||||
import com.keylesspalace.tusky.appstore.NewNotificationsEvent
|
import com.keylesspalace.tusky.appstore.NewNotificationsEvent
|
||||||
import com.keylesspalace.tusky.components.notifications.NotificationHelper.filterNotification
|
import com.keylesspalace.tusky.components.systemnotifications.NotificationHelper.filterNotification
|
||||||
import com.keylesspalace.tusky.db.AccountEntity
|
|
||||||
import com.keylesspalace.tusky.db.AccountManager
|
import com.keylesspalace.tusky.db.AccountManager
|
||||||
|
import com.keylesspalace.tusky.db.entity.AccountEntity
|
||||||
import com.keylesspalace.tusky.entity.Marker
|
import com.keylesspalace.tusky.entity.Marker
|
||||||
import com.keylesspalace.tusky.entity.Notification
|
import com.keylesspalace.tusky.entity.Notification
|
||||||
import com.keylesspalace.tusky.network.MastodonApi
|
import com.keylesspalace.tusky.network.MastodonApi
|
|
@ -14,7 +14,7 @@
|
||||||
* You should have received a copy of the GNU General Public License along with Tusky; if not,
|
* You should have received a copy of the GNU General Public License along with Tusky; if not,
|
||||||
* see <http://www.gnu.org/licenses>. */
|
* see <http://www.gnu.org/licenses>. */
|
||||||
|
|
||||||
package com.keylesspalace.tusky.components.notifications;
|
package com.keylesspalace.tusky.components.systemnotifications;
|
||||||
|
|
||||||
import static com.keylesspalace.tusky.BuildConfig.APPLICATION_ID;
|
import static com.keylesspalace.tusky.BuildConfig.APPLICATION_ID;
|
||||||
import static com.keylesspalace.tusky.util.StatusParsingHelper.parseAsMastodonHtml;
|
import static com.keylesspalace.tusky.util.StatusParsingHelper.parseAsMastodonHtml;
|
||||||
|
@ -55,7 +55,7 @@ import com.bumptech.glide.request.FutureTarget;
|
||||||
import com.keylesspalace.tusky.MainActivity;
|
import com.keylesspalace.tusky.MainActivity;
|
||||||
import com.keylesspalace.tusky.R;
|
import com.keylesspalace.tusky.R;
|
||||||
import com.keylesspalace.tusky.components.compose.ComposeActivity;
|
import com.keylesspalace.tusky.components.compose.ComposeActivity;
|
||||||
import com.keylesspalace.tusky.db.AccountEntity;
|
import com.keylesspalace.tusky.db.entity.AccountEntity;
|
||||||
import com.keylesspalace.tusky.db.AccountManager;
|
import com.keylesspalace.tusky.db.AccountManager;
|
||||||
import com.keylesspalace.tusky.entity.Notification;
|
import com.keylesspalace.tusky.entity.Notification;
|
||||||
import com.keylesspalace.tusky.entity.Poll;
|
import com.keylesspalace.tusky.entity.Poll;
|
|
@ -15,7 +15,7 @@
|
||||||
|
|
||||||
@file:JvmName("PushNotificationHelper")
|
@file:JvmName("PushNotificationHelper")
|
||||||
|
|
||||||
package com.keylesspalace.tusky.components.notifications
|
package com.keylesspalace.tusky.components.systemnotifications
|
||||||
|
|
||||||
import android.app.NotificationManager
|
import android.app.NotificationManager
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
@ -29,8 +29,8 @@ import at.connyduck.calladapter.networkresult.onSuccess
|
||||||
import com.google.android.material.snackbar.Snackbar
|
import com.google.android.material.snackbar.Snackbar
|
||||||
import com.keylesspalace.tusky.R
|
import com.keylesspalace.tusky.R
|
||||||
import com.keylesspalace.tusky.components.login.LoginActivity
|
import com.keylesspalace.tusky.components.login.LoginActivity
|
||||||
import com.keylesspalace.tusky.db.AccountEntity
|
|
||||||
import com.keylesspalace.tusky.db.AccountManager
|
import com.keylesspalace.tusky.db.AccountManager
|
||||||
|
import com.keylesspalace.tusky.db.entity.AccountEntity
|
||||||
import com.keylesspalace.tusky.entity.Notification
|
import com.keylesspalace.tusky.entity.Notification
|
||||||
import com.keylesspalace.tusky.network.MastodonApi
|
import com.keylesspalace.tusky.network.MastodonApi
|
||||||
import com.keylesspalace.tusky.util.CryptoUtil
|
import com.keylesspalace.tusky.util.CryptoUtil
|
|
@ -24,6 +24,7 @@ import com.keylesspalace.tusky.R
|
||||||
import com.keylesspalace.tusky.adapter.PlaceholderViewHolder
|
import com.keylesspalace.tusky.adapter.PlaceholderViewHolder
|
||||||
import com.keylesspalace.tusky.adapter.StatusBaseViewHolder
|
import com.keylesspalace.tusky.adapter.StatusBaseViewHolder
|
||||||
import com.keylesspalace.tusky.adapter.StatusViewHolder
|
import com.keylesspalace.tusky.adapter.StatusViewHolder
|
||||||
|
import com.keylesspalace.tusky.databinding.ItemStatusPlaceholderBinding
|
||||||
import com.keylesspalace.tusky.entity.Filter
|
import com.keylesspalace.tusky.entity.Filter
|
||||||
import com.keylesspalace.tusky.interfaces.StatusActionListener
|
import com.keylesspalace.tusky.interfaces.StatusActionListener
|
||||||
import com.keylesspalace.tusky.util.StatusDisplayOptions
|
import com.keylesspalace.tusky.util.StatusDisplayOptions
|
||||||
|
@ -54,7 +55,8 @@ class TimelinePagingAdapter(
|
||||||
}
|
}
|
||||||
VIEW_TYPE_PLACEHOLDER -> {
|
VIEW_TYPE_PLACEHOLDER -> {
|
||||||
PlaceholderViewHolder(
|
PlaceholderViewHolder(
|
||||||
inflater.inflate(R.layout.item_status_placeholder, viewGroup, false)
|
ItemStatusPlaceholderBinding.inflate(inflater, viewGroup, false),
|
||||||
|
statusListener
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
else -> {
|
else -> {
|
||||||
|
@ -83,7 +85,7 @@ class TimelinePagingAdapter(
|
||||||
val status = getItem(position)
|
val status = getItem(position)
|
||||||
if (status is StatusViewData.Placeholder) {
|
if (status is StatusViewData.Placeholder) {
|
||||||
val holder = viewHolder as PlaceholderViewHolder
|
val holder = viewHolder as PlaceholderViewHolder
|
||||||
holder.setup(statusListener, status.isLoading)
|
holder.setup(status.isLoading)
|
||||||
} else if (status is StatusViewData.Concrete) {
|
} else if (status is StatusViewData.Concrete) {
|
||||||
val holder = viewHolder as StatusViewHolder
|
val holder = viewHolder as StatusViewHolder
|
||||||
holder.setupWithStatus(
|
holder.setupWithStatus(
|
||||||
|
|
|
@ -17,45 +17,36 @@
|
||||||
|
|
||||||
package com.keylesspalace.tusky.components.timeline
|
package com.keylesspalace.tusky.components.timeline
|
||||||
|
|
||||||
import android.util.Log
|
import com.keylesspalace.tusky.db.entity.HomeTimelineData
|
||||||
import com.keylesspalace.tusky.db.TimelineAccountEntity
|
import com.keylesspalace.tusky.db.entity.HomeTimelineEntity
|
||||||
import com.keylesspalace.tusky.db.TimelineStatusEntity
|
import com.keylesspalace.tusky.db.entity.TimelineAccountEntity
|
||||||
import com.keylesspalace.tusky.db.TimelineStatusWithAccount
|
import com.keylesspalace.tusky.db.entity.TimelineStatusEntity
|
||||||
import com.keylesspalace.tusky.entity.Attachment
|
|
||||||
import com.keylesspalace.tusky.entity.Card
|
|
||||||
import com.keylesspalace.tusky.entity.Emoji
|
|
||||||
import com.keylesspalace.tusky.entity.HashTag
|
|
||||||
import com.keylesspalace.tusky.entity.Poll
|
|
||||||
import com.keylesspalace.tusky.entity.Status
|
import com.keylesspalace.tusky.entity.Status
|
||||||
import com.keylesspalace.tusky.entity.TimelineAccount
|
import com.keylesspalace.tusky.entity.TimelineAccount
|
||||||
import com.keylesspalace.tusky.viewdata.StatusViewData
|
import com.keylesspalace.tusky.viewdata.StatusViewData
|
||||||
import com.keylesspalace.tusky.viewdata.TranslationViewData
|
import com.keylesspalace.tusky.viewdata.TranslationViewData
|
||||||
import com.squareup.moshi.Moshi
|
|
||||||
import com.squareup.moshi.adapter
|
|
||||||
import java.util.Date
|
import java.util.Date
|
||||||
|
|
||||||
private const val TAG = "TimelineTypeMappers"
|
|
||||||
|
|
||||||
data class Placeholder(
|
data class Placeholder(
|
||||||
val id: String,
|
val id: String,
|
||||||
val loading: Boolean
|
val loading: Boolean
|
||||||
)
|
)
|
||||||
|
|
||||||
fun TimelineAccount.toEntity(accountId: Long, moshi: Moshi): TimelineAccountEntity {
|
fun TimelineAccount.toEntity(tuskyAccountId: Long): TimelineAccountEntity {
|
||||||
return TimelineAccountEntity(
|
return TimelineAccountEntity(
|
||||||
serverId = id,
|
serverId = id,
|
||||||
timelineUserId = accountId,
|
tuskyAccountId = tuskyAccountId,
|
||||||
localUsername = localUsername,
|
localUsername = localUsername,
|
||||||
username = username,
|
username = username,
|
||||||
displayName = name,
|
displayName = name,
|
||||||
url = url,
|
url = url,
|
||||||
avatar = avatar,
|
avatar = avatar,
|
||||||
emojis = moshi.adapter<List<Emoji>>().toJson(emojis),
|
emojis = emojis,
|
||||||
bot = bot
|
bot = bot
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun TimelineAccountEntity.toAccount(moshi: Moshi): TimelineAccount {
|
fun TimelineAccountEntity.toAccount(): TimelineAccount {
|
||||||
return TimelineAccount(
|
return TimelineAccount(
|
||||||
id = serverId,
|
id = serverId,
|
||||||
localUsername = localUsername,
|
localUsername = localUsername,
|
||||||
|
@ -65,120 +56,114 @@ fun TimelineAccountEntity.toAccount(moshi: Moshi): TimelineAccount {
|
||||||
url = url,
|
url = url,
|
||||||
avatar = avatar,
|
avatar = avatar,
|
||||||
bot = bot,
|
bot = bot,
|
||||||
emojis = moshi.adapter<List<Emoji>?>().fromJson(emojis).orEmpty()
|
emojis = emojis
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun Placeholder.toEntity(timelineUserId: Long): TimelineStatusEntity {
|
fun Placeholder.toEntity(tuskyAccountId: Long): HomeTimelineEntity {
|
||||||
return TimelineStatusEntity(
|
return HomeTimelineEntity(
|
||||||
serverId = this.id,
|
id = this.id,
|
||||||
url = null,
|
tuskyAccountId = tuskyAccountId,
|
||||||
timelineUserId = timelineUserId,
|
statusId = null,
|
||||||
authorServerId = null,
|
|
||||||
inReplyToId = null,
|
|
||||||
inReplyToAccountId = null,
|
|
||||||
content = null,
|
|
||||||
createdAt = 0L,
|
|
||||||
editedAt = 0L,
|
|
||||||
emojis = null,
|
|
||||||
reblogsCount = 0,
|
|
||||||
favouritesCount = 0,
|
|
||||||
reblogged = false,
|
|
||||||
favourited = false,
|
|
||||||
bookmarked = false,
|
|
||||||
sensitive = false,
|
|
||||||
spoilerText = "",
|
|
||||||
visibility = Status.Visibility.UNKNOWN,
|
|
||||||
attachments = null,
|
|
||||||
mentions = null,
|
|
||||||
tags = null,
|
|
||||||
application = null,
|
|
||||||
reblogServerId = null,
|
|
||||||
reblogAccountId = null,
|
reblogAccountId = null,
|
||||||
poll = null,
|
loading = this.loading
|
||||||
muted = false,
|
|
||||||
expanded = loading,
|
|
||||||
contentCollapsed = false,
|
|
||||||
contentShowing = false,
|
|
||||||
pinned = false,
|
|
||||||
card = null,
|
|
||||||
repliesCount = 0,
|
|
||||||
language = null,
|
|
||||||
filtered = emptyList()
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun Status.toEntity(
|
fun Status.toEntity(
|
||||||
timelineUserId: Long,
|
tuskyAccountId: Long,
|
||||||
moshi: Moshi,
|
|
||||||
expanded: Boolean,
|
expanded: Boolean,
|
||||||
contentShowing: Boolean,
|
contentShowing: Boolean,
|
||||||
contentCollapsed: Boolean
|
contentCollapsed: Boolean
|
||||||
): TimelineStatusEntity {
|
) = TimelineStatusEntity(
|
||||||
return TimelineStatusEntity(
|
serverId = id,
|
||||||
serverId = this.id,
|
url = actionableStatus.url,
|
||||||
url = actionableStatus.url,
|
tuskyAccountId = tuskyAccountId,
|
||||||
timelineUserId = timelineUserId,
|
authorServerId = actionableStatus.account.id,
|
||||||
authorServerId = actionableStatus.account.id,
|
inReplyToId = actionableStatus.inReplyToId,
|
||||||
inReplyToId = actionableStatus.inReplyToId,
|
inReplyToAccountId = actionableStatus.inReplyToAccountId,
|
||||||
inReplyToAccountId = actionableStatus.inReplyToAccountId,
|
content = actionableStatus.content,
|
||||||
content = actionableStatus.content,
|
createdAt = actionableStatus.createdAt.time,
|
||||||
createdAt = actionableStatus.createdAt.time,
|
editedAt = actionableStatus.editedAt?.time,
|
||||||
editedAt = actionableStatus.editedAt?.time,
|
emojis = actionableStatus.emojis,
|
||||||
emojis = actionableStatus.emojis.let { moshi.adapter<List<Emoji>>().toJson(it) },
|
reblogsCount = actionableStatus.reblogsCount,
|
||||||
reblogsCount = actionableStatus.reblogsCount,
|
favouritesCount = actionableStatus.favouritesCount,
|
||||||
favouritesCount = actionableStatus.favouritesCount,
|
reblogged = actionableStatus.reblogged,
|
||||||
reblogged = actionableStatus.reblogged,
|
favourited = actionableStatus.favourited,
|
||||||
favourited = actionableStatus.favourited,
|
bookmarked = actionableStatus.bookmarked,
|
||||||
bookmarked = actionableStatus.bookmarked,
|
sensitive = actionableStatus.sensitive,
|
||||||
sensitive = actionableStatus.sensitive,
|
spoilerText = actionableStatus.spoilerText,
|
||||||
spoilerText = actionableStatus.spoilerText,
|
visibility = actionableStatus.visibility,
|
||||||
visibility = actionableStatus.visibility,
|
attachments = actionableStatus.attachments,
|
||||||
attachments = actionableStatus.attachments.let { moshi.adapter<List<Attachment>>().toJson(it) },
|
mentions = actionableStatus.mentions,
|
||||||
mentions = actionableStatus.mentions.let { moshi.adapter<List<Status.Mention>>().toJson(it) },
|
tags = actionableStatus.tags,
|
||||||
tags = actionableStatus.tags.let { moshi.adapter<List<HashTag>?>().toJson(it) },
|
application = actionableStatus.application,
|
||||||
application = actionableStatus.application.let { moshi.adapter<Status.Application?>().toJson(it) },
|
poll = actionableStatus.poll,
|
||||||
reblogServerId = reblog?.id,
|
muted = actionableStatus.muted,
|
||||||
reblogAccountId = reblog?.let { this.account.id },
|
expanded = expanded,
|
||||||
poll = actionableStatus.poll.let { moshi.adapter<Poll?>().toJson(it) },
|
contentShowing = contentShowing,
|
||||||
muted = actionableStatus.muted,
|
contentCollapsed = contentCollapsed,
|
||||||
expanded = expanded,
|
pinned = actionableStatus.pinned,
|
||||||
contentShowing = contentShowing,
|
card = actionableStatus.card,
|
||||||
contentCollapsed = contentCollapsed,
|
repliesCount = actionableStatus.repliesCount,
|
||||||
pinned = actionableStatus.pinned,
|
language = actionableStatus.language,
|
||||||
card = actionableStatus.card?.let { moshi.adapter<Card>().toJson(it) },
|
filtered = actionableStatus.filtered
|
||||||
repliesCount = actionableStatus.repliesCount,
|
)
|
||||||
language = actionableStatus.language,
|
|
||||||
filtered = actionableStatus.filtered
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun TimelineStatusWithAccount.toViewData(moshi: Moshi, isDetailed: Boolean = false, translation: TranslationViewData? = null): StatusViewData {
|
fun TimelineStatusEntity.toStatus(
|
||||||
if (this.account == null) {
|
account: TimelineAccountEntity
|
||||||
Log.d(TAG, "Constructing Placeholder(${this.status.serverId}, ${this.status.expanded})")
|
) = Status(
|
||||||
return StatusViewData.Placeholder(this.status.serverId, this.status.expanded)
|
id = serverId,
|
||||||
|
url = url,
|
||||||
|
account = account.toAccount(),
|
||||||
|
inReplyToId = inReplyToId,
|
||||||
|
inReplyToAccountId = inReplyToAccountId,
|
||||||
|
reblog = null,
|
||||||
|
content = content,
|
||||||
|
createdAt = Date(createdAt),
|
||||||
|
editedAt = editedAt?.let { Date(it) },
|
||||||
|
emojis = emojis,
|
||||||
|
reblogsCount = reblogsCount,
|
||||||
|
favouritesCount = favouritesCount,
|
||||||
|
reblogged = reblogged,
|
||||||
|
favourited = favourited,
|
||||||
|
bookmarked = bookmarked,
|
||||||
|
sensitive = sensitive,
|
||||||
|
spoilerText = spoilerText,
|
||||||
|
visibility = visibility,
|
||||||
|
attachments = attachments,
|
||||||
|
mentions = mentions,
|
||||||
|
tags = tags,
|
||||||
|
application = application,
|
||||||
|
pinned = false,
|
||||||
|
muted = muted,
|
||||||
|
poll = poll,
|
||||||
|
card = card,
|
||||||
|
repliesCount = repliesCount,
|
||||||
|
language = language,
|
||||||
|
filtered = filtered,
|
||||||
|
)
|
||||||
|
|
||||||
|
fun HomeTimelineData.toViewData(isDetailed: Boolean = false, translation: TranslationViewData? = null): StatusViewData {
|
||||||
|
if (this.account == null || this.status == null) {
|
||||||
|
return StatusViewData.Placeholder(this.id, loading)
|
||||||
}
|
}
|
||||||
|
|
||||||
val attachments: List<Attachment> = status.attachments?.let { moshi.adapter<List<Attachment>?>().fromJson(it) }.orEmpty()
|
val originalStatus = status.toStatus(account)
|
||||||
val mentions: List<Status.Mention> = status.mentions?.let { moshi.adapter<List<Status.Mention>?>().fromJson(it) }.orEmpty()
|
val status = if (reblogAccount != null) {
|
||||||
val tags: List<HashTag>? = status.tags?.let { moshi.adapter<List<HashTag>?>().fromJson(it) }
|
|
||||||
val application = status.application?.let { moshi.adapter<Status.Application?>().fromJson(it) }
|
|
||||||
val emojis: List<Emoji> = status.emojis?.let { moshi.adapter<List<Emoji>?>().fromJson(it) }.orEmpty()
|
|
||||||
val poll: Poll? = status.poll?.let { moshi.adapter<Poll?>().fromJson(it) }
|
|
||||||
val card: Card? = status.card?.let { moshi.adapter<Card?>().fromJson(it) }
|
|
||||||
|
|
||||||
val reblog = status.reblogServerId?.let { id ->
|
|
||||||
Status(
|
Status(
|
||||||
id = id,
|
id = id,
|
||||||
url = status.url,
|
// no url for reblogs
|
||||||
account = account.toAccount(moshi),
|
url = null,
|
||||||
|
account = reblogAccount.toAccount(),
|
||||||
inReplyToId = status.inReplyToId,
|
inReplyToId = status.inReplyToId,
|
||||||
inReplyToAccountId = status.inReplyToAccountId,
|
inReplyToAccountId = status.inReplyToAccountId,
|
||||||
reblog = null,
|
reblog = originalStatus,
|
||||||
content = status.content.orEmpty(),
|
content = status.content,
|
||||||
|
// lie but whatever?
|
||||||
createdAt = Date(status.createdAt),
|
createdAt = Date(status.createdAt),
|
||||||
editedAt = status.editedAt?.let { Date(it) },
|
editedAt = null,
|
||||||
emojis = emojis,
|
emojis = emptyList(),
|
||||||
reblogsCount = status.reblogsCount,
|
reblogsCount = status.reblogsCount,
|
||||||
favouritesCount = status.favouritesCount,
|
favouritesCount = status.favouritesCount,
|
||||||
reblogged = status.reblogged,
|
reblogged = status.reblogged,
|
||||||
|
@ -187,86 +172,22 @@ fun TimelineStatusWithAccount.toViewData(moshi: Moshi, isDetailed: Boolean = fal
|
||||||
sensitive = status.sensitive,
|
sensitive = status.sensitive,
|
||||||
spoilerText = status.spoilerText,
|
spoilerText = status.spoilerText,
|
||||||
visibility = status.visibility,
|
visibility = status.visibility,
|
||||||
attachments = attachments,
|
|
||||||
mentions = mentions,
|
|
||||||
tags = tags,
|
|
||||||
application = application,
|
|
||||||
pinned = false,
|
|
||||||
muted = status.muted ?: false,
|
|
||||||
poll = poll,
|
|
||||||
card = card,
|
|
||||||
repliesCount = status.repliesCount,
|
|
||||||
language = status.language,
|
|
||||||
filtered = status.filtered.orEmpty(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
val status = if (reblog != null) {
|
|
||||||
Status(
|
|
||||||
id = status.serverId,
|
|
||||||
// no url for reblogs
|
|
||||||
url = null,
|
|
||||||
account = this.reblogAccount!!.toAccount(moshi),
|
|
||||||
inReplyToId = null,
|
|
||||||
inReplyToAccountId = null,
|
|
||||||
reblog = reblog,
|
|
||||||
content = "",
|
|
||||||
// lie but whatever?
|
|
||||||
createdAt = Date(status.createdAt),
|
|
||||||
editedAt = null,
|
|
||||||
emojis = emptyList(),
|
|
||||||
reblogsCount = 0,
|
|
||||||
favouritesCount = 0,
|
|
||||||
reblogged = false,
|
|
||||||
favourited = false,
|
|
||||||
bookmarked = false,
|
|
||||||
sensitive = false,
|
|
||||||
spoilerText = "",
|
|
||||||
visibility = status.visibility,
|
|
||||||
attachments = emptyList(),
|
attachments = emptyList(),
|
||||||
mentions = emptyList(),
|
mentions = emptyList(),
|
||||||
tags = emptyList(),
|
tags = emptyList(),
|
||||||
application = null,
|
application = null,
|
||||||
pinned = status.pinned,
|
pinned = false,
|
||||||
muted = status.muted ?: false,
|
muted = status.muted,
|
||||||
poll = null,
|
poll = null,
|
||||||
card = null,
|
card = null,
|
||||||
repliesCount = status.repliesCount,
|
repliesCount = status.repliesCount,
|
||||||
language = status.language,
|
language = status.language,
|
||||||
filtered = status.filtered.orEmpty()
|
filtered = status.filtered,
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
Status(
|
originalStatus
|
||||||
id = status.serverId,
|
|
||||||
url = status.url,
|
|
||||||
account = account.toAccount(moshi),
|
|
||||||
inReplyToId = status.inReplyToId,
|
|
||||||
inReplyToAccountId = status.inReplyToAccountId,
|
|
||||||
reblog = null,
|
|
||||||
content = translation?.data?.content ?: status.content.orEmpty(),
|
|
||||||
createdAt = Date(status.createdAt),
|
|
||||||
editedAt = status.editedAt?.let { Date(it) },
|
|
||||||
emojis = emojis,
|
|
||||||
reblogsCount = status.reblogsCount,
|
|
||||||
favouritesCount = status.favouritesCount,
|
|
||||||
reblogged = status.reblogged,
|
|
||||||
favourited = status.favourited,
|
|
||||||
bookmarked = status.bookmarked,
|
|
||||||
sensitive = status.sensitive,
|
|
||||||
spoilerText = status.spoilerText,
|
|
||||||
visibility = status.visibility,
|
|
||||||
attachments = attachments,
|
|
||||||
mentions = mentions,
|
|
||||||
tags = tags,
|
|
||||||
application = application,
|
|
||||||
pinned = status.pinned,
|
|
||||||
muted = status.muted ?: false,
|
|
||||||
poll = poll,
|
|
||||||
card = card,
|
|
||||||
repliesCount = status.repliesCount,
|
|
||||||
language = status.language,
|
|
||||||
filtered = status.filtered.orEmpty()
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return StatusViewData.Concrete(
|
return StatusViewData.Concrete(
|
||||||
status = status,
|
status = status,
|
||||||
isExpanded = this.status.expanded,
|
isExpanded = this.status.expanded,
|
||||||
|
|
|
@ -26,11 +26,11 @@ import com.keylesspalace.tusky.components.timeline.toEntity
|
||||||
import com.keylesspalace.tusky.components.timeline.util.ifExpected
|
import com.keylesspalace.tusky.components.timeline.util.ifExpected
|
||||||
import com.keylesspalace.tusky.db.AccountManager
|
import com.keylesspalace.tusky.db.AccountManager
|
||||||
import com.keylesspalace.tusky.db.AppDatabase
|
import com.keylesspalace.tusky.db.AppDatabase
|
||||||
import com.keylesspalace.tusky.db.TimelineStatusEntity
|
import com.keylesspalace.tusky.db.entity.HomeTimelineData
|
||||||
import com.keylesspalace.tusky.db.TimelineStatusWithAccount
|
import com.keylesspalace.tusky.db.entity.HomeTimelineEntity
|
||||||
|
import com.keylesspalace.tusky.db.entity.TimelineStatusEntity
|
||||||
import com.keylesspalace.tusky.entity.Status
|
import com.keylesspalace.tusky.entity.Status
|
||||||
import com.keylesspalace.tusky.network.MastodonApi
|
import com.keylesspalace.tusky.network.MastodonApi
|
||||||
import com.squareup.moshi.Moshi
|
|
||||||
import retrofit2.HttpException
|
import retrofit2.HttpException
|
||||||
|
|
||||||
@OptIn(ExperimentalPagingApi::class)
|
@OptIn(ExperimentalPagingApi::class)
|
||||||
|
@ -38,17 +38,18 @@ class CachedTimelineRemoteMediator(
|
||||||
accountManager: AccountManager,
|
accountManager: AccountManager,
|
||||||
private val api: MastodonApi,
|
private val api: MastodonApi,
|
||||||
private val db: AppDatabase,
|
private val db: AppDatabase,
|
||||||
private val moshi: Moshi
|
) : RemoteMediator<Int, HomeTimelineData>() {
|
||||||
) : RemoteMediator<Int, TimelineStatusWithAccount>() {
|
|
||||||
|
|
||||||
private var initialRefresh = false
|
private var initialRefresh = false
|
||||||
|
|
||||||
private val timelineDao = db.timelineDao()
|
private val timelineDao = db.timelineDao()
|
||||||
|
private val statusDao = db.timelineStatusDao()
|
||||||
|
private val accountDao = db.timelineAccountDao()
|
||||||
private val activeAccount = accountManager.activeAccount!!
|
private val activeAccount = accountManager.activeAccount!!
|
||||||
|
|
||||||
override suspend fun load(
|
override suspend fun load(
|
||||||
loadType: LoadType,
|
loadType: LoadType,
|
||||||
state: PagingState<Int, TimelineStatusWithAccount>
|
state: PagingState<Int, HomeTimelineData>
|
||||||
): MediatorResult {
|
): MediatorResult {
|
||||||
if (!activeAccount.isLoggedIn()) {
|
if (!activeAccount.isLoggedIn()) {
|
||||||
return MediatorResult.Success(endOfPaginationReached = true)
|
return MediatorResult.Success(endOfPaginationReached = true)
|
||||||
|
@ -111,7 +112,7 @@ class CachedTimelineRemoteMediator(
|
||||||
/* This overrides the last of the newly loaded statuses with a placeholder
|
/* This overrides the last of the newly loaded statuses with a placeholder
|
||||||
to guarantee the placeholder has an id that exists on the server as not all
|
to guarantee the placeholder has an id that exists on the server as not all
|
||||||
servers handle client generated ids as expected */
|
servers handle client generated ids as expected */
|
||||||
timelineDao.insertStatus(
|
timelineDao.insertHomeTimelineItem(
|
||||||
Placeholder(statuses.last().id, loading = false).toEntity(activeAccount.id)
|
Placeholder(statuses.last().id, loading = false).toEntity(activeAccount.id)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -134,7 +135,7 @@ class CachedTimelineRemoteMediator(
|
||||||
*/
|
*/
|
||||||
private suspend fun replaceStatusRange(
|
private suspend fun replaceStatusRange(
|
||||||
statuses: List<Status>,
|
statuses: List<Status>,
|
||||||
state: PagingState<Int, TimelineStatusWithAccount>
|
state: PagingState<Int, HomeTimelineData>
|
||||||
): Int {
|
): Int {
|
||||||
val overlappedStatuses = if (statuses.isNotEmpty()) {
|
val overlappedStatuses = if (statuses.isNotEmpty()) {
|
||||||
timelineDao.deleteRange(activeAccount.id, statuses.last().id, statuses.first().id)
|
timelineDao.deleteRange(activeAccount.id, statuses.last().id, statuses.first().id)
|
||||||
|
@ -143,9 +144,9 @@ class CachedTimelineRemoteMediator(
|
||||||
}
|
}
|
||||||
|
|
||||||
for (status in statuses) {
|
for (status in statuses) {
|
||||||
timelineDao.insertAccount(status.account.toEntity(activeAccount.id, moshi))
|
accountDao.insert(status.account.toEntity(activeAccount.id))
|
||||||
status.reblog?.account?.toEntity(activeAccount.id, moshi)?.let { rebloggedAccount ->
|
status.reblog?.account?.toEntity(activeAccount.id)?.let { rebloggedAccount ->
|
||||||
timelineDao.insertAccount(rebloggedAccount)
|
accountDao.insert(rebloggedAccount)
|
||||||
}
|
}
|
||||||
|
|
||||||
// check if we already have one of the newly loaded statuses cached locally
|
// check if we already have one of the newly loaded statuses cached locally
|
||||||
|
@ -153,31 +154,35 @@ class CachedTimelineRemoteMediator(
|
||||||
var oldStatus: TimelineStatusEntity? = null
|
var oldStatus: TimelineStatusEntity? = null
|
||||||
for (page in state.pages) {
|
for (page in state.pages) {
|
||||||
oldStatus = page.data.find { s ->
|
oldStatus = page.data.find { s ->
|
||||||
s.status.serverId == status.id
|
s.status?.serverId == status.actionableId
|
||||||
}?.status
|
}?.status
|
||||||
if (oldStatus != null) break
|
if (oldStatus != null) break
|
||||||
}
|
}
|
||||||
|
|
||||||
// The "expanded" property for Placeholders determines whether or not they are
|
val expanded = oldStatus?.expanded ?: activeAccount.alwaysOpenSpoiler
|
||||||
// in the "loading" state, and should not be affected by the account's
|
|
||||||
// "alwaysOpenSpoiler" preference
|
|
||||||
val expanded = if (oldStatus?.isPlaceholder == true) {
|
|
||||||
oldStatus.expanded
|
|
||||||
} else {
|
|
||||||
oldStatus?.expanded ?: activeAccount.alwaysOpenSpoiler
|
|
||||||
}
|
|
||||||
val contentShowing = oldStatus?.contentShowing ?: (activeAccount.alwaysShowSensitiveMedia || !status.actionableStatus.sensitive)
|
val contentShowing = oldStatus?.contentShowing ?: (activeAccount.alwaysShowSensitiveMedia || !status.actionableStatus.sensitive)
|
||||||
val contentCollapsed = oldStatus?.contentCollapsed ?: true
|
val contentCollapsed = oldStatus?.contentCollapsed ?: true
|
||||||
|
|
||||||
timelineDao.insertStatus(
|
statusDao.insert(
|
||||||
status.toEntity(
|
status.actionableStatus.toEntity(
|
||||||
timelineUserId = activeAccount.id,
|
tuskyAccountId = activeAccount.id,
|
||||||
moshi = moshi,
|
|
||||||
expanded = expanded,
|
expanded = expanded,
|
||||||
contentShowing = contentShowing,
|
contentShowing = contentShowing,
|
||||||
contentCollapsed = contentCollapsed
|
contentCollapsed = contentCollapsed
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
timelineDao.insertHomeTimelineItem(
|
||||||
|
HomeTimelineEntity(
|
||||||
|
tuskyAccountId = activeAccount.id,
|
||||||
|
id = status.id,
|
||||||
|
statusId = status.actionableId,
|
||||||
|
reblogAccountId = if (status.reblog != null) {
|
||||||
|
status.account.id
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
return overlappedStatuses
|
return overlappedStatuses
|
||||||
}
|
}
|
||||||
|
|
|
@ -38,20 +38,18 @@ import com.keylesspalace.tusky.components.timeline.toViewData
|
||||||
import com.keylesspalace.tusky.components.timeline.util.ifExpected
|
import com.keylesspalace.tusky.components.timeline.util.ifExpected
|
||||||
import com.keylesspalace.tusky.db.AccountManager
|
import com.keylesspalace.tusky.db.AccountManager
|
||||||
import com.keylesspalace.tusky.db.AppDatabase
|
import com.keylesspalace.tusky.db.AppDatabase
|
||||||
import com.keylesspalace.tusky.db.TimelineStatusWithAccount
|
import com.keylesspalace.tusky.db.entity.HomeTimelineData
|
||||||
|
import com.keylesspalace.tusky.db.entity.HomeTimelineEntity
|
||||||
import com.keylesspalace.tusky.entity.Filter
|
import com.keylesspalace.tusky.entity.Filter
|
||||||
import com.keylesspalace.tusky.entity.Poll
|
import com.keylesspalace.tusky.entity.Poll
|
||||||
import com.keylesspalace.tusky.entity.Status
|
|
||||||
import com.keylesspalace.tusky.network.FilterModel
|
import com.keylesspalace.tusky.network.FilterModel
|
||||||
import com.keylesspalace.tusky.network.MastodonApi
|
import com.keylesspalace.tusky.network.MastodonApi
|
||||||
import com.keylesspalace.tusky.usecase.TimelineCases
|
import com.keylesspalace.tusky.usecase.TimelineCases
|
||||||
import com.keylesspalace.tusky.util.EmptyPagingSource
|
import com.keylesspalace.tusky.util.EmptyPagingSource
|
||||||
import com.keylesspalace.tusky.viewdata.StatusViewData
|
import com.keylesspalace.tusky.viewdata.StatusViewData
|
||||||
import com.keylesspalace.tusky.viewdata.TranslationViewData
|
import com.keylesspalace.tusky.viewdata.TranslationViewData
|
||||||
import com.squareup.moshi.Moshi
|
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.asExecutor
|
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.combine
|
import kotlinx.coroutines.flow.combine
|
||||||
import kotlinx.coroutines.flow.flowOn
|
import kotlinx.coroutines.flow.flowOn
|
||||||
|
@ -68,8 +66,7 @@ class CachedTimelineViewModel @Inject constructor(
|
||||||
accountManager: AccountManager,
|
accountManager: AccountManager,
|
||||||
sharedPreferences: SharedPreferences,
|
sharedPreferences: SharedPreferences,
|
||||||
filterModel: FilterModel,
|
filterModel: FilterModel,
|
||||||
private val db: AppDatabase,
|
private val db: AppDatabase
|
||||||
private val moshi: Moshi
|
|
||||||
) : TimelineViewModel(
|
) : TimelineViewModel(
|
||||||
timelineCases,
|
timelineCases,
|
||||||
api,
|
api,
|
||||||
|
@ -79,7 +76,7 @@ class CachedTimelineViewModel @Inject constructor(
|
||||||
filterModel
|
filterModel
|
||||||
) {
|
) {
|
||||||
|
|
||||||
private var currentPagingSource: PagingSource<Int, TimelineStatusWithAccount>? = null
|
private var currentPagingSource: PagingSource<Int, HomeTimelineData>? = null
|
||||||
|
|
||||||
/** Map from status id to translation. */
|
/** Map from status id to translation. */
|
||||||
private val translations = MutableStateFlow(mapOf<String, TranslationViewData>())
|
private val translations = MutableStateFlow(mapOf<String, TranslationViewData>())
|
||||||
|
@ -87,13 +84,13 @@ class CachedTimelineViewModel @Inject constructor(
|
||||||
@OptIn(ExperimentalPagingApi::class)
|
@OptIn(ExperimentalPagingApi::class)
|
||||||
override val statuses = Pager(
|
override val statuses = Pager(
|
||||||
config = PagingConfig(pageSize = LOAD_AT_ONCE),
|
config = PagingConfig(pageSize = LOAD_AT_ONCE),
|
||||||
remoteMediator = CachedTimelineRemoteMediator(accountManager, api, db, moshi),
|
remoteMediator = CachedTimelineRemoteMediator(accountManager, api, db),
|
||||||
pagingSourceFactory = {
|
pagingSourceFactory = {
|
||||||
val activeAccount = accountManager.activeAccount
|
val activeAccount = accountManager.activeAccount
|
||||||
if (activeAccount == null) {
|
if (activeAccount == null) {
|
||||||
EmptyPagingSource()
|
EmptyPagingSource()
|
||||||
} else {
|
} else {
|
||||||
db.timelineDao().getStatuses(activeAccount.id)
|
db.timelineDao().getHomeTimeline(activeAccount.id)
|
||||||
}.also { newPagingSource ->
|
}.also { newPagingSource ->
|
||||||
this.currentPagingSource = newPagingSource
|
this.currentPagingSource = newPagingSource
|
||||||
}
|
}
|
||||||
|
@ -105,14 +102,13 @@ class CachedTimelineViewModel @Inject constructor(
|
||||||
// adding another cachedIn() for the overall result.
|
// adding another cachedIn() for the overall result.
|
||||||
.cachedIn(viewModelScope)
|
.cachedIn(viewModelScope)
|
||||||
.combine(translations) { pagingData, translations ->
|
.combine(translations) { pagingData, translations ->
|
||||||
pagingData.map(Dispatchers.Default.asExecutor()) { timelineStatus ->
|
pagingData.map { timelineData ->
|
||||||
val translation = translations[timelineStatus.status.serverId]
|
val translation = translations[timelineData.status?.serverId]
|
||||||
timelineStatus.toViewData(
|
timelineData.toViewData(
|
||||||
moshi,
|
|
||||||
isDetailed = false,
|
isDetailed = false,
|
||||||
translation = translation
|
translation = translation
|
||||||
)
|
)
|
||||||
}.filter(Dispatchers.Default.asExecutor()) { statusViewData ->
|
}.filter { statusViewData ->
|
||||||
shouldFilterStatus(statusViewData) != Filter.Action.HIDE
|
shouldFilterStatus(statusViewData) != Filter.Action.HIDE
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -124,39 +120,28 @@ class CachedTimelineViewModel @Inject constructor(
|
||||||
|
|
||||||
override fun changeExpanded(expanded: Boolean, status: StatusViewData.Concrete) {
|
override fun changeExpanded(expanded: Boolean, status: StatusViewData.Concrete) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
db.timelineDao().setExpanded(accountManager.activeAccount!!.id, status.id, expanded)
|
db.timelineStatusDao()
|
||||||
|
.setExpanded(accountManager.activeAccount!!.id, status.actionableId, expanded)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun changeContentShowing(isShowing: Boolean, status: StatusViewData.Concrete) {
|
override fun changeContentShowing(isShowing: Boolean, status: StatusViewData.Concrete) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
db.timelineDao()
|
db.timelineStatusDao()
|
||||||
.setContentShowing(accountManager.activeAccount!!.id, status.id, isShowing)
|
.setContentShowing(accountManager.activeAccount!!.id, status.actionableId, isShowing)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun changeContentCollapsed(isCollapsed: Boolean, status: StatusViewData.Concrete) {
|
override fun changeContentCollapsed(isCollapsed: Boolean, status: StatusViewData.Concrete) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
db.timelineDao()
|
db.timelineStatusDao()
|
||||||
.setContentCollapsed(accountManager.activeAccount!!.id, status.id, isCollapsed)
|
.setContentCollapsed(accountManager.activeAccount!!.id, status.actionableId, isCollapsed)
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun removeAllByAccountId(accountId: String) {
|
|
||||||
viewModelScope.launch {
|
|
||||||
db.timelineDao().removeAllByUser(accountManager.activeAccount!!.id, accountId)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun removeAllByInstance(instance: String) {
|
|
||||||
viewModelScope.launch {
|
|
||||||
db.timelineDao().deleteAllFromInstance(accountManager.activeAccount!!.id, instance)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun clearWarning(status: StatusViewData.Concrete) {
|
override fun clearWarning(status: StatusViewData.Concrete) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
db.timelineDao().clearWarning(accountManager.activeAccount!!.id, status.actionableId)
|
db.timelineStatusDao().clearWarning(accountManager.activeAccount!!.id, status.actionableId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -168,10 +153,12 @@ class CachedTimelineViewModel @Inject constructor(
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
try {
|
try {
|
||||||
val timelineDao = db.timelineDao()
|
val timelineDao = db.timelineDao()
|
||||||
|
val statusDao = db.timelineStatusDao()
|
||||||
|
val accountDao = db.timelineAccountDao()
|
||||||
|
|
||||||
val activeAccount = accountManager.activeAccount!!
|
val activeAccount = accountManager.activeAccount!!
|
||||||
|
|
||||||
timelineDao.insertStatus(
|
timelineDao.insertHomeTimelineItem(
|
||||||
Placeholder(placeholderId, loading = true).toEntity(
|
Placeholder(placeholderId, loading = true).toEntity(
|
||||||
activeAccount.id
|
activeAccount.id
|
||||||
)
|
)
|
||||||
|
@ -205,7 +192,7 @@ class CachedTimelineViewModel @Inject constructor(
|
||||||
}
|
}
|
||||||
|
|
||||||
db.withTransaction {
|
db.withTransaction {
|
||||||
timelineDao.delete(activeAccount.id, placeholderId)
|
timelineDao.deleteHomeTimelineItem(activeAccount.id, placeholderId)
|
||||||
|
|
||||||
val overlappedStatuses = if (statuses.isNotEmpty()) {
|
val overlappedStatuses = if (statuses.isNotEmpty()) {
|
||||||
timelineDao.deleteRange(
|
timelineDao.deleteRange(
|
||||||
|
@ -218,20 +205,31 @@ class CachedTimelineViewModel @Inject constructor(
|
||||||
}
|
}
|
||||||
|
|
||||||
for (status in statuses) {
|
for (status in statuses) {
|
||||||
timelineDao.insertAccount(status.account.toEntity(activeAccount.id, moshi))
|
accountDao.insert(status.account.toEntity(activeAccount.id))
|
||||||
status.reblog?.account?.toEntity(activeAccount.id, moshi)
|
status.reblog?.account?.toEntity(activeAccount.id)
|
||||||
?.let { rebloggedAccount ->
|
?.let { rebloggedAccount ->
|
||||||
timelineDao.insertAccount(rebloggedAccount)
|
accountDao.insert(rebloggedAccount)
|
||||||
}
|
}
|
||||||
timelineDao.insertStatus(
|
statusDao.insert(
|
||||||
status.toEntity(
|
status.actionableStatus.toEntity(
|
||||||
timelineUserId = activeAccount.id,
|
tuskyAccountId = activeAccount.id,
|
||||||
moshi = moshi,
|
|
||||||
expanded = activeAccount.alwaysOpenSpoiler,
|
expanded = activeAccount.alwaysOpenSpoiler,
|
||||||
contentShowing = activeAccount.alwaysShowSensitiveMedia || !status.actionableStatus.sensitive,
|
contentShowing = activeAccount.alwaysShowSensitiveMedia || !status.actionableStatus.sensitive,
|
||||||
contentCollapsed = true
|
contentCollapsed = true
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
timelineDao.insertHomeTimelineItem(
|
||||||
|
HomeTimelineEntity(
|
||||||
|
tuskyAccountId = activeAccount.id,
|
||||||
|
id = status.id,
|
||||||
|
statusId = status.actionableId,
|
||||||
|
reblogAccountId = if (status.reblog != null) {
|
||||||
|
status.account.id
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/* In case we loaded a whole page and there was no overlap with existing statuses,
|
/* In case we loaded a whole page and there was no overlap with existing statuses,
|
||||||
|
@ -244,7 +242,7 @@ class CachedTimelineViewModel @Inject constructor(
|
||||||
OLDEST_FIRST -> statuses.first().id
|
OLDEST_FIRST -> statuses.first().id
|
||||||
NEWEST_FIRST -> statuses.last().id
|
NEWEST_FIRST -> statuses.last().id
|
||||||
}
|
}
|
||||||
timelineDao.insertStatus(
|
timelineDao.insertHomeTimelineItem(
|
||||||
Placeholder(
|
Placeholder(
|
||||||
idToConvert,
|
idToConvert,
|
||||||
loading = false
|
loading = false
|
||||||
|
@ -264,17 +262,13 @@ class CachedTimelineViewModel @Inject constructor(
|
||||||
Log.w("CachedTimelineVM", "failed loading statuses", e)
|
Log.w("CachedTimelineVM", "failed loading statuses", e)
|
||||||
val activeAccount = accountManager.activeAccount!!
|
val activeAccount = accountManager.activeAccount!!
|
||||||
db.timelineDao()
|
db.timelineDao()
|
||||||
.insertStatus(Placeholder(placeholderId, loading = false).toEntity(activeAccount.id))
|
.insertHomeTimelineItem(Placeholder(placeholderId, loading = false).toEntity(activeAccount.id))
|
||||||
}
|
|
||||||
|
|
||||||
override fun handleStatusChangedEvent(status: Status) {
|
|
||||||
// handled by CacheUpdater
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun fullReload() {
|
override fun fullReload() {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
val activeAccount = accountManager.activeAccount!!
|
val activeAccount = accountManager.activeAccount!!
|
||||||
db.timelineDao().removeAll(activeAccount.id)
|
db.timelineDao().removeAllHomeTimelineItems(activeAccount.id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -288,7 +282,7 @@ class CachedTimelineViewModel @Inject constructor(
|
||||||
|
|
||||||
override suspend fun invalidate() {
|
override suspend fun invalidate() {
|
||||||
// invalidating when we don't have statuses yet can cause empty timelines because it cancels the network load
|
// invalidating when we don't have statuses yet can cause empty timelines because it cancels the network load
|
||||||
if (db.timelineDao().getStatusCount(accountManager.activeAccount!!.id) > 0) {
|
if (db.timelineDao().getHomeTimelineItemCount(accountManager.activeAccount!!.id) > 0) {
|
||||||
currentPagingSource?.invalidate()
|
currentPagingSource?.invalidate()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -26,7 +26,14 @@ import androidx.paging.filter
|
||||||
import at.connyduck.calladapter.networkresult.NetworkResult
|
import at.connyduck.calladapter.networkresult.NetworkResult
|
||||||
import at.connyduck.calladapter.networkresult.map
|
import at.connyduck.calladapter.networkresult.map
|
||||||
import at.connyduck.calladapter.networkresult.onFailure
|
import at.connyduck.calladapter.networkresult.onFailure
|
||||||
|
import com.keylesspalace.tusky.appstore.BlockEvent
|
||||||
|
import com.keylesspalace.tusky.appstore.DomainMuteEvent
|
||||||
|
import com.keylesspalace.tusky.appstore.Event
|
||||||
import com.keylesspalace.tusky.appstore.EventHub
|
import com.keylesspalace.tusky.appstore.EventHub
|
||||||
|
import com.keylesspalace.tusky.appstore.MuteEvent
|
||||||
|
import com.keylesspalace.tusky.appstore.StatusChangedEvent
|
||||||
|
import com.keylesspalace.tusky.appstore.StatusDeletedEvent
|
||||||
|
import com.keylesspalace.tusky.appstore.UnfollowEvent
|
||||||
import com.keylesspalace.tusky.components.timeline.util.ifExpected
|
import com.keylesspalace.tusky.components.timeline.util.ifExpected
|
||||||
import com.keylesspalace.tusky.db.AccountManager
|
import com.keylesspalace.tusky.db.AccountManager
|
||||||
import com.keylesspalace.tusky.entity.Filter
|
import com.keylesspalace.tusky.entity.Filter
|
||||||
|
@ -96,6 +103,48 @@ class NetworkTimelineViewModel @Inject constructor(
|
||||||
.flowOn(Dispatchers.Default)
|
.flowOn(Dispatchers.Default)
|
||||||
.cachedIn(viewModelScope)
|
.cachedIn(viewModelScope)
|
||||||
|
|
||||||
|
init {
|
||||||
|
viewModelScope.launch {
|
||||||
|
eventHub.events
|
||||||
|
.collect { event -> handleEvent(event) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleEvent(event: Event) {
|
||||||
|
when (event) {
|
||||||
|
is StatusChangedEvent -> handleStatusChangedEvent(event.status)
|
||||||
|
is UnfollowEvent -> {
|
||||||
|
if (kind == Kind.HOME) {
|
||||||
|
val id = event.accountId
|
||||||
|
removeAllByAccountId(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is BlockEvent -> {
|
||||||
|
if (kind != Kind.USER && kind != Kind.USER_WITH_REPLIES && kind != Kind.USER_PINNED) {
|
||||||
|
val id = event.accountId
|
||||||
|
removeAllByAccountId(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is MuteEvent -> {
|
||||||
|
if (kind != Kind.USER && kind != Kind.USER_WITH_REPLIES && kind != Kind.USER_PINNED) {
|
||||||
|
val id = event.accountId
|
||||||
|
removeAllByAccountId(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is DomainMuteEvent -> {
|
||||||
|
if (kind != Kind.USER && kind != Kind.USER_WITH_REPLIES && kind != Kind.USER_PINNED) {
|
||||||
|
val instance = event.instance
|
||||||
|
removeAllByInstance(instance)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is StatusDeletedEvent -> {
|
||||||
|
if (kind != Kind.USER && kind != Kind.USER_WITH_REPLIES && kind != Kind.USER_PINNED) {
|
||||||
|
removeStatusWithId(event.statusId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
override fun updatePoll(newPoll: Poll, status: StatusViewData.Concrete) {
|
override fun updatePoll(newPoll: Poll, status: StatusViewData.Concrete) {
|
||||||
status.copy(
|
status.copy(
|
||||||
status = status.status.copy(poll = newPoll)
|
status = status.status.copy(poll = newPoll)
|
||||||
|
@ -120,7 +169,7 @@ class NetworkTimelineViewModel @Inject constructor(
|
||||||
).update()
|
).update()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun removeAllByAccountId(accountId: String) {
|
private fun removeAllByAccountId(accountId: String) {
|
||||||
statusData.removeAll { vd ->
|
statusData.removeAll { vd ->
|
||||||
val status = vd.asStatusOrNull()?.status ?: return@removeAll false
|
val status = vd.asStatusOrNull()?.status ?: return@removeAll false
|
||||||
status.account.id == accountId || status.actionableStatus.account.id == accountId
|
status.account.id == accountId || status.actionableStatus.account.id == accountId
|
||||||
|
@ -128,7 +177,7 @@ class NetworkTimelineViewModel @Inject constructor(
|
||||||
currentSource?.invalidate()
|
currentSource?.invalidate()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun removeAllByInstance(instance: String) {
|
private fun removeAllByInstance(instance: String) {
|
||||||
statusData.removeAll { vd ->
|
statusData.removeAll { vd ->
|
||||||
val status = vd.asStatusOrNull()?.status ?: return@removeAll false
|
val status = vd.asStatusOrNull()?.status ?: return@removeAll false
|
||||||
getDomain(status.account.url) == instance
|
getDomain(status.account.url) == instance
|
||||||
|
@ -241,7 +290,7 @@ class NetworkTimelineViewModel @Inject constructor(
|
||||||
currentSource?.invalidate()
|
currentSource?.invalidate()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun handleStatusChangedEvent(status: Status) {
|
private fun handleStatusChangedEvent(status: Status) {
|
||||||
updateStatusById(status.id) { oldViewData ->
|
updateStatusById(status.id) { oldViewData ->
|
||||||
status.toViewData(
|
status.toViewData(
|
||||||
isShowingContent = oldViewData.isShowingContent,
|
isShowingContent = oldViewData.isShowingContent,
|
||||||
|
|
|
@ -24,24 +24,17 @@ import at.connyduck.calladapter.networkresult.NetworkResult
|
||||||
import at.connyduck.calladapter.networkresult.fold
|
import at.connyduck.calladapter.networkresult.fold
|
||||||
import at.connyduck.calladapter.networkresult.getOrElse
|
import at.connyduck.calladapter.networkresult.getOrElse
|
||||||
import at.connyduck.calladapter.networkresult.getOrThrow
|
import at.connyduck.calladapter.networkresult.getOrThrow
|
||||||
import com.keylesspalace.tusky.appstore.BlockEvent
|
|
||||||
import com.keylesspalace.tusky.appstore.DomainMuteEvent
|
|
||||||
import com.keylesspalace.tusky.appstore.Event
|
import com.keylesspalace.tusky.appstore.Event
|
||||||
import com.keylesspalace.tusky.appstore.EventHub
|
import com.keylesspalace.tusky.appstore.EventHub
|
||||||
import com.keylesspalace.tusky.appstore.FilterUpdatedEvent
|
import com.keylesspalace.tusky.appstore.FilterUpdatedEvent
|
||||||
import com.keylesspalace.tusky.appstore.MuteConversationEvent
|
import com.keylesspalace.tusky.appstore.MuteConversationEvent
|
||||||
import com.keylesspalace.tusky.appstore.MuteEvent
|
|
||||||
import com.keylesspalace.tusky.appstore.PreferenceChangedEvent
|
import com.keylesspalace.tusky.appstore.PreferenceChangedEvent
|
||||||
import com.keylesspalace.tusky.appstore.StatusChangedEvent
|
|
||||||
import com.keylesspalace.tusky.appstore.StatusDeletedEvent
|
|
||||||
import com.keylesspalace.tusky.appstore.UnfollowEvent
|
|
||||||
import com.keylesspalace.tusky.components.preference.PreferencesFragment.ReadingOrder
|
import com.keylesspalace.tusky.components.preference.PreferencesFragment.ReadingOrder
|
||||||
import com.keylesspalace.tusky.components.timeline.util.ifExpected
|
import com.keylesspalace.tusky.components.timeline.util.ifExpected
|
||||||
import com.keylesspalace.tusky.db.AccountManager
|
import com.keylesspalace.tusky.db.AccountManager
|
||||||
import com.keylesspalace.tusky.entity.Filter
|
import com.keylesspalace.tusky.entity.Filter
|
||||||
import com.keylesspalace.tusky.entity.FilterV1
|
import com.keylesspalace.tusky.entity.FilterV1
|
||||||
import com.keylesspalace.tusky.entity.Poll
|
import com.keylesspalace.tusky.entity.Poll
|
||||||
import com.keylesspalace.tusky.entity.Status
|
|
||||||
import com.keylesspalace.tusky.network.FilterModel
|
import com.keylesspalace.tusky.network.FilterModel
|
||||||
import com.keylesspalace.tusky.network.MastodonApi
|
import com.keylesspalace.tusky.network.MastodonApi
|
||||||
import com.keylesspalace.tusky.settings.PrefKeys
|
import com.keylesspalace.tusky.settings.PrefKeys
|
||||||
|
@ -162,16 +155,10 @@ abstract class TimelineViewModel(
|
||||||
|
|
||||||
abstract fun changeContentCollapsed(isCollapsed: Boolean, status: StatusViewData.Concrete)
|
abstract fun changeContentCollapsed(isCollapsed: Boolean, status: StatusViewData.Concrete)
|
||||||
|
|
||||||
abstract fun removeAllByAccountId(accountId: String)
|
|
||||||
|
|
||||||
abstract fun removeAllByInstance(instance: String)
|
|
||||||
|
|
||||||
abstract fun removeStatusWithId(id: String)
|
abstract fun removeStatusWithId(id: String)
|
||||||
|
|
||||||
abstract fun loadMore(placeholderId: String)
|
abstract fun loadMore(placeholderId: String)
|
||||||
|
|
||||||
abstract fun handleStatusChangedEvent(status: Status)
|
|
||||||
|
|
||||||
abstract fun fullReload()
|
abstract fun fullReload()
|
||||||
|
|
||||||
abstract fun clearWarning(status: StatusViewData.Concrete)
|
abstract fun clearWarning(status: StatusViewData.Concrete)
|
||||||
|
@ -240,37 +227,7 @@ abstract class TimelineViewModel(
|
||||||
|
|
||||||
private fun handleEvent(event: Event) {
|
private fun handleEvent(event: Event) {
|
||||||
when (event) {
|
when (event) {
|
||||||
is StatusChangedEvent -> handleStatusChangedEvent(event.status)
|
|
||||||
is MuteConversationEvent -> fullReload()
|
is MuteConversationEvent -> fullReload()
|
||||||
is UnfollowEvent -> {
|
|
||||||
if (kind == Kind.HOME) {
|
|
||||||
val id = event.accountId
|
|
||||||
removeAllByAccountId(id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
is BlockEvent -> {
|
|
||||||
if (kind != Kind.USER && kind != Kind.USER_WITH_REPLIES && kind != Kind.USER_PINNED) {
|
|
||||||
val id = event.accountId
|
|
||||||
removeAllByAccountId(id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
is MuteEvent -> {
|
|
||||||
if (kind != Kind.USER && kind != Kind.USER_WITH_REPLIES && kind != Kind.USER_PINNED) {
|
|
||||||
val id = event.accountId
|
|
||||||
removeAllByAccountId(id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
is DomainMuteEvent -> {
|
|
||||||
if (kind != Kind.USER && kind != Kind.USER_WITH_REPLIES && kind != Kind.USER_PINNED) {
|
|
||||||
val instance = event.instance
|
|
||||||
removeAllByInstance(instance)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
is StatusDeletedEvent -> {
|
|
||||||
if (kind != Kind.USER && kind != Kind.USER_WITH_REPLIES && kind != Kind.USER_PINNED) {
|
|
||||||
removeStatusWithId(event.statusId)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
is PreferenceChangedEvent -> {
|
is PreferenceChangedEvent -> {
|
||||||
onPreferenceChanged(event.preferenceKey)
|
onPreferenceChanged(event.preferenceKey)
|
||||||
}
|
}
|
||||||
|
|
|
@ -24,12 +24,13 @@ import at.connyduck.calladapter.networkresult.getOrElse
|
||||||
import at.connyduck.calladapter.networkresult.getOrThrow
|
import at.connyduck.calladapter.networkresult.getOrThrow
|
||||||
import at.connyduck.calladapter.networkresult.map
|
import at.connyduck.calladapter.networkresult.map
|
||||||
import at.connyduck.calladapter.networkresult.onFailure
|
import at.connyduck.calladapter.networkresult.onFailure
|
||||||
|
import at.connyduck.calladapter.networkresult.onSuccess
|
||||||
import com.keylesspalace.tusky.appstore.BlockEvent
|
import com.keylesspalace.tusky.appstore.BlockEvent
|
||||||
import com.keylesspalace.tusky.appstore.EventHub
|
import com.keylesspalace.tusky.appstore.EventHub
|
||||||
import com.keylesspalace.tusky.appstore.StatusChangedEvent
|
import com.keylesspalace.tusky.appstore.StatusChangedEvent
|
||||||
import com.keylesspalace.tusky.appstore.StatusComposedEvent
|
import com.keylesspalace.tusky.appstore.StatusComposedEvent
|
||||||
import com.keylesspalace.tusky.appstore.StatusDeletedEvent
|
import com.keylesspalace.tusky.appstore.StatusDeletedEvent
|
||||||
import com.keylesspalace.tusky.components.timeline.toViewData
|
import com.keylesspalace.tusky.components.timeline.toStatus
|
||||||
import com.keylesspalace.tusky.components.timeline.util.ifExpected
|
import com.keylesspalace.tusky.components.timeline.util.ifExpected
|
||||||
import com.keylesspalace.tusky.db.AccountManager
|
import com.keylesspalace.tusky.db.AccountManager
|
||||||
import com.keylesspalace.tusky.db.AppDatabase
|
import com.keylesspalace.tusky.db.AppDatabase
|
||||||
|
@ -108,24 +109,18 @@ class ViewThreadViewModel @Inject constructor(
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
Log.d(TAG, "Finding status with: $id")
|
Log.d(TAG, "Finding status with: $id")
|
||||||
val contextCall = async { api.statusContext(id) }
|
val contextCall = async { api.statusContext(id) }
|
||||||
val timelineStatus = db.timelineDao().getStatus(accountManager.activeAccount!!.id, id)
|
val statusAndAccount = db.timelineStatusDao().getStatusWithAccount(accountManager.activeAccount!!.id, id)
|
||||||
|
|
||||||
var detailedStatus = if (timelineStatus != null) {
|
var detailedStatus = if (statusAndAccount != null) {
|
||||||
Log.d(TAG, "Loaded status from local timeline")
|
Log.d(TAG, "Loaded status from local timeline")
|
||||||
val viewData = timelineStatus.toViewData(
|
StatusViewData.Concrete(
|
||||||
moshi,
|
status = statusAndAccount.first.toStatus(statusAndAccount.second),
|
||||||
|
isExpanded = statusAndAccount.first.expanded,
|
||||||
|
isShowingContent = statusAndAccount.first.contentShowing,
|
||||||
|
isCollapsed = statusAndAccount.first.contentCollapsed,
|
||||||
isDetailed = true,
|
isDetailed = true,
|
||||||
) as StatusViewData.Concrete
|
translation = null
|
||||||
|
)
|
||||||
// Return the correct status, depending on which one matched. If you do not do
|
|
||||||
// this the status IDs will be different between the status that's displayed with
|
|
||||||
// ThreadUiState.LoadingThread and ThreadUiState.Success, even though the apparent
|
|
||||||
// status content is the same. Then the status flickers as it is drawn twice.
|
|
||||||
if (viewData.actionableId == id) {
|
|
||||||
viewData.actionable.toViewData(isDetailed = true)
|
|
||||||
} else {
|
|
||||||
viewData
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
Log.d(TAG, "Loaded status from network")
|
Log.d(TAG, "Loaded status from network")
|
||||||
val result = api.status(id).getOrElse { exception ->
|
val result = api.status(id).getOrElse { exception ->
|
||||||
|
@ -143,12 +138,13 @@ class ViewThreadViewModel @Inject constructor(
|
||||||
// If the detailedStatus was loaded from the database it might be out-of-date
|
// If the detailedStatus was loaded from the database it might be out-of-date
|
||||||
// compared to the remote one. Now the user has a working UI do a background fetch
|
// compared to the remote one. Now the user has a working UI do a background fetch
|
||||||
// for the status. Ignore errors, the user still has a functioning UI if the fetch
|
// for the status. Ignore errors, the user still has a functioning UI if the fetch
|
||||||
// failed.
|
// failed. Update the database when the fetch was successful.
|
||||||
if (timelineStatus != null) {
|
if (statusAndAccount != null) {
|
||||||
api.status(id).getOrNull()?.let { result ->
|
api.status(id).onSuccess { result ->
|
||||||
db.timelineDao().update(
|
db.timelineStatusDao().update(
|
||||||
accountId = accountManager.activeAccount!!.id,
|
tuskyAccountId = accountManager.activeAccount!!.id,
|
||||||
status = result
|
status = result,
|
||||||
|
moshi = moshi
|
||||||
)
|
)
|
||||||
detailedStatus = result.toViewData(isDetailed = true)
|
detailedStatus = result.toViewData(isDetailed = true)
|
||||||
}
|
}
|
||||||
|
|
|
@ -18,6 +18,8 @@ package com.keylesspalace.tusky.db
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import androidx.preference.PreferenceManager
|
import androidx.preference.PreferenceManager
|
||||||
|
import com.keylesspalace.tusky.db.dao.AccountDao
|
||||||
|
import com.keylesspalace.tusky.db.entity.AccountEntity
|
||||||
import com.keylesspalace.tusky.entity.Account
|
import com.keylesspalace.tusky.entity.Account
|
||||||
import com.keylesspalace.tusky.entity.Status
|
import com.keylesspalace.tusky.entity.Status
|
||||||
import com.keylesspalace.tusky.settings.PrefKeys
|
import com.keylesspalace.tusky.settings.PrefKeys
|
||||||
|
|
|
@ -27,6 +27,21 @@ import androidx.sqlite.db.SupportSQLiteDatabase;
|
||||||
|
|
||||||
import com.keylesspalace.tusky.TabDataKt;
|
import com.keylesspalace.tusky.TabDataKt;
|
||||||
import com.keylesspalace.tusky.components.conversation.ConversationEntity;
|
import com.keylesspalace.tusky.components.conversation.ConversationEntity;
|
||||||
|
import com.keylesspalace.tusky.db.dao.AccountDao;
|
||||||
|
import com.keylesspalace.tusky.db.dao.DraftDao;
|
||||||
|
import com.keylesspalace.tusky.db.dao.InstanceDao;
|
||||||
|
import com.keylesspalace.tusky.db.dao.NotificationsDao;
|
||||||
|
import com.keylesspalace.tusky.db.dao.TimelineAccountDao;
|
||||||
|
import com.keylesspalace.tusky.db.dao.TimelineDao;
|
||||||
|
import com.keylesspalace.tusky.db.dao.TimelineStatusDao;
|
||||||
|
import com.keylesspalace.tusky.db.entity.AccountEntity;
|
||||||
|
import com.keylesspalace.tusky.db.entity.DraftEntity;
|
||||||
|
import com.keylesspalace.tusky.db.entity.HomeTimelineEntity;
|
||||||
|
import com.keylesspalace.tusky.db.entity.InstanceEntity;
|
||||||
|
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.db.entity.TimelineStatusEntity;
|
||||||
|
|
||||||
import java.io.File;
|
import java.io.File;
|
||||||
|
|
||||||
|
@ -40,11 +55,14 @@ import java.io.File;
|
||||||
InstanceEntity.class,
|
InstanceEntity.class,
|
||||||
TimelineStatusEntity.class,
|
TimelineStatusEntity.class,
|
||||||
TimelineAccountEntity.class,
|
TimelineAccountEntity.class,
|
||||||
ConversationEntity.class
|
ConversationEntity.class,
|
||||||
|
NotificationEntity.class,
|
||||||
|
NotificationReportEntity.class,
|
||||||
|
HomeTimelineEntity.class
|
||||||
},
|
},
|
||||||
// Note: Starting with version 54, database versions in Tusky are always even.
|
// Note: Starting with version 54, database versions in Tusky are always even.
|
||||||
// This is to reserve odd version numbers for use by forks.
|
// This is to reserve odd version numbers for use by forks.
|
||||||
version = 58,
|
version = 60,
|
||||||
autoMigrations = {
|
autoMigrations = {
|
||||||
@AutoMigration(from = 48, to = 49),
|
@AutoMigration(from = 48, to = 49),
|
||||||
@AutoMigration(from = 49, to = 50, spec = AppDatabase.MIGRATION_49_50.class),
|
@AutoMigration(from = 49, to = 50, spec = AppDatabase.MIGRATION_49_50.class),
|
||||||
|
@ -61,6 +79,9 @@ public abstract class AppDatabase extends RoomDatabase {
|
||||||
@NonNull public abstract ConversationsDao conversationDao();
|
@NonNull public abstract ConversationsDao conversationDao();
|
||||||
@NonNull public abstract TimelineDao timelineDao();
|
@NonNull public abstract TimelineDao timelineDao();
|
||||||
@NonNull public abstract DraftDao draftDao();
|
@NonNull public abstract DraftDao draftDao();
|
||||||
|
@NonNull public abstract NotificationsDao notificationsDao();
|
||||||
|
@NonNull public abstract TimelineStatusDao timelineStatusDao();
|
||||||
|
@NonNull public abstract TimelineAccountDao timelineAccountDao();
|
||||||
|
|
||||||
public static final Migration MIGRATION_2_3 = new Migration(2, 3) {
|
public static final Migration MIGRATION_2_3 = new Migration(2, 3) {
|
||||||
@Override
|
@Override
|
||||||
|
@ -698,4 +719,126 @@ public abstract class AppDatabase extends RoomDatabase {
|
||||||
database.execSQL("ALTER TABLE `AccountEntity` ADD COLUMN `isShowHomeSelfBoosts` INTEGER NOT NULL DEFAULT 1");
|
database.execSQL("ALTER TABLE `AccountEntity` ADD COLUMN `isShowHomeSelfBoosts` INTEGER NOT NULL DEFAULT 1");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
public static final Migration MIGRATION_58_60 = new Migration(58, 60) {
|
||||||
|
@Override
|
||||||
|
public void migrate(@NonNull SupportSQLiteDatabase database) {
|
||||||
|
// drop the old tables - they are only caches anyway
|
||||||
|
database.execSQL("DROP TABLE `TimelineStatusEntity`");
|
||||||
|
database.execSQL("DROP TABLE `TimelineAccountEntity`");
|
||||||
|
|
||||||
|
// create the new tables
|
||||||
|
database.execSQL("""
|
||||||
|
CREATE TABLE IF NOT EXISTS `TimelineAccountEntity` (
|
||||||
|
`serverId` TEXT NOT NULL,
|
||||||
|
`tuskyAccountId` INTEGER NOT NULL,
|
||||||
|
`localUsername` TEXT NOT NULL,
|
||||||
|
`username` TEXT NOT NULL,
|
||||||
|
`displayName` TEXT NOT NULL,
|
||||||
|
`url` TEXT NOT NULL,
|
||||||
|
`avatar` TEXT NOT NULL,
|
||||||
|
`emojis` TEXT NOT NULL,
|
||||||
|
`bot` INTEGER NOT NULL,
|
||||||
|
PRIMARY KEY(`serverId`, `tuskyAccountId`)
|
||||||
|
)"""
|
||||||
|
);
|
||||||
|
database.execSQL("""
|
||||||
|
CREATE TABLE IF NOT EXISTS `TimelineStatusEntity` (
|
||||||
|
`serverId` TEXT NOT NULL,
|
||||||
|
`url` TEXT,
|
||||||
|
`tuskyAccountId` INTEGER NOT NULL,
|
||||||
|
`authorServerId` TEXT NOT NULL,
|
||||||
|
`inReplyToId` TEXT,
|
||||||
|
`inReplyToAccountId` TEXT,
|
||||||
|
`content` TEXT NOT NULL,
|
||||||
|
`createdAt` INTEGER NOT NULL,
|
||||||
|
`editedAt` INTEGER,
|
||||||
|
`emojis` TEXT NOT NULL,
|
||||||
|
`reblogsCount` INTEGER NOT NULL,
|
||||||
|
`favouritesCount` INTEGER NOT NULL,
|
||||||
|
`repliesCount` INTEGER NOT NULL,
|
||||||
|
`reblogged` INTEGER NOT NULL,
|
||||||
|
`bookmarked` INTEGER NOT NULL,
|
||||||
|
`favourited` INTEGER NOT NULL,
|
||||||
|
`sensitive` INTEGER NOT NULL,
|
||||||
|
`spoilerText` TEXT NOT NULL,
|
||||||
|
`visibility` INTEGER NOT NULL,
|
||||||
|
`attachments` TEXT NOT NULL,
|
||||||
|
`mentions` TEXT NOT NULL,
|
||||||
|
`tags` TEXT NOT NULL,
|
||||||
|
`application` TEXT,
|
||||||
|
`poll` TEXT,
|
||||||
|
`muted` INTEGER NOT NULL,
|
||||||
|
`expanded` INTEGER NOT NULL,
|
||||||
|
`contentCollapsed` INTEGER NOT NULL,
|
||||||
|
`contentShowing` INTEGER NOT NULL,
|
||||||
|
`pinned` INTEGER NOT NULL,
|
||||||
|
`card` TEXT, `language` TEXT,
|
||||||
|
`filtered` TEXT NOT NULL,
|
||||||
|
PRIMARY KEY(`serverId`, `tuskyAccountId`),
|
||||||
|
FOREIGN KEY(`authorServerId`, `tuskyAccountId`) REFERENCES `TimelineAccountEntity`(`serverId`, `tuskyAccountId`) ON UPDATE NO ACTION ON DELETE NO ACTION
|
||||||
|
)"""
|
||||||
|
);
|
||||||
|
database.execSQL(
|
||||||
|
"CREATE INDEX IF NOT EXISTS `index_TimelineStatusEntity_authorServerId_tuskyAccountId` ON `TimelineStatusEntity` (`authorServerId`, `tuskyAccountId`)"
|
||||||
|
);
|
||||||
|
database.execSQL("""
|
||||||
|
CREATE TABLE IF NOT EXISTS `HomeTimelineEntity` (
|
||||||
|
`tuskyAccountId` INTEGER NOT NULL,
|
||||||
|
`id` TEXT NOT NULL,
|
||||||
|
`statusId` TEXT,
|
||||||
|
`reblogAccountId` TEXT,
|
||||||
|
`loading` INTEGER NOT NULL,
|
||||||
|
PRIMARY KEY(`id`, `tuskyAccountId`),
|
||||||
|
FOREIGN KEY(`statusId`, `tuskyAccountId`) REFERENCES `TimelineStatusEntity`(`serverId`, `tuskyAccountId`) ON UPDATE NO ACTION ON DELETE NO ACTION,
|
||||||
|
FOREIGN KEY(`reblogAccountId`, `tuskyAccountId`) REFERENCES `TimelineAccountEntity`(`serverId`, `tuskyAccountId`) ON UPDATE NO ACTION ON DELETE NO ACTION
|
||||||
|
)"""
|
||||||
|
);
|
||||||
|
database.execSQL(
|
||||||
|
"CREATE INDEX IF NOT EXISTS `index_HomeTimelineEntity_statusId_tuskyAccountId` ON `HomeTimelineEntity` (`statusId`, `tuskyAccountId`)"
|
||||||
|
);
|
||||||
|
database.execSQL(
|
||||||
|
"CREATE INDEX IF NOT EXISTS `index_HomeTimelineEntity_reblogAccountId_tuskyAccountId` ON `HomeTimelineEntity` (`reblogAccountId`, `tuskyAccountId`)"
|
||||||
|
);
|
||||||
|
database.execSQL("""
|
||||||
|
CREATE TABLE IF NOT EXISTS `NotificationReportEntity`(
|
||||||
|
`tuskyAccountId` INTEGER NOT NULL,
|
||||||
|
`serverId` TEXT NOT NULL,
|
||||||
|
`category` TEXT NOT NULL,
|
||||||
|
`statusIds` TEXT,
|
||||||
|
`createdAt` INTEGER NOT NULL,
|
||||||
|
`targetAccountId` TEXT,
|
||||||
|
PRIMARY KEY(`serverId`, `tuskyAccountId`),
|
||||||
|
FOREIGN KEY(`targetAccountId`, `tuskyAccountId`) REFERENCES `TimelineAccountEntity`(`serverId`, `tuskyAccountId`) ON UPDATE NO ACTION ON DELETE NO ACTION
|
||||||
|
)"""
|
||||||
|
);
|
||||||
|
database.execSQL(
|
||||||
|
"CREATE INDEX IF NOT EXISTS `index_NotificationReportEntity_targetAccountId_tuskyAccountId` ON `NotificationReportEntity` (`targetAccountId`, `tuskyAccountId`)"
|
||||||
|
);
|
||||||
|
database.execSQL("""
|
||||||
|
CREATE TABLE IF NOT EXISTS `NotificationEntity` (
|
||||||
|
`tuskyAccountId` INTEGER NOT NULL,
|
||||||
|
`type` TEXT,
|
||||||
|
`id` TEXT NOT NULL,
|
||||||
|
`accountId` TEXT,
|
||||||
|
`statusId` TEXT,
|
||||||
|
`reportId` TEXT,
|
||||||
|
`loading` INTEGER NOT NULL,
|
||||||
|
PRIMARY KEY(`id`, `tuskyAccountId`),
|
||||||
|
FOREIGN KEY(`accountId`, `tuskyAccountId`) REFERENCES `TimelineAccountEntity`(`serverId`, `tuskyAccountId`) ON UPDATE NO ACTION ON DELETE NO ACTION,
|
||||||
|
FOREIGN KEY(`statusId`, `tuskyAccountId`) REFERENCES `TimelineStatusEntity`(`serverId`, `tuskyAccountId`) ON UPDATE NO ACTION ON DELETE NO ACTION,
|
||||||
|
FOREIGN KEY(`reportId`, `tuskyAccountId`) REFERENCES `NotificationReportEntity`(`serverId`, `tuskyAccountId`) ON UPDATE NO ACTION ON DELETE NO ACTION
|
||||||
|
)"""
|
||||||
|
);
|
||||||
|
database.execSQL(
|
||||||
|
"CREATE INDEX IF NOT EXISTS `index_NotificationEntity_accountId_tuskyAccountId` ON `NotificationEntity` (`accountId`, `tuskyAccountId`)"
|
||||||
|
);
|
||||||
|
database.execSQL(
|
||||||
|
"CREATE INDEX IF NOT EXISTS `index_NotificationEntity_statusId_tuskyAccountId` ON `NotificationEntity` (`statusId`, `tuskyAccountId`)"
|
||||||
|
);
|
||||||
|
database.execSQL(
|
||||||
|
"CREATE INDEX IF NOT EXISTS `index_NotificationEntity_reportId_tuskyAccountId` ON `NotificationEntity` (`reportId`, `tuskyAccountId`)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
|
@ -20,6 +20,7 @@ import androidx.room.TypeConverter
|
||||||
import com.keylesspalace.tusky.TabData
|
import com.keylesspalace.tusky.TabData
|
||||||
import com.keylesspalace.tusky.components.conversation.ConversationAccountEntity
|
import com.keylesspalace.tusky.components.conversation.ConversationAccountEntity
|
||||||
import com.keylesspalace.tusky.createTabDataFromId
|
import com.keylesspalace.tusky.createTabDataFromId
|
||||||
|
import com.keylesspalace.tusky.db.entity.DraftAttachment
|
||||||
import com.keylesspalace.tusky.entity.Attachment
|
import com.keylesspalace.tusky.entity.Attachment
|
||||||
import com.keylesspalace.tusky.entity.Card
|
import com.keylesspalace.tusky.entity.Card
|
||||||
import com.keylesspalace.tusky.entity.Emoji
|
import com.keylesspalace.tusky.entity.Emoji
|
||||||
|
@ -187,4 +188,29 @@ class Converters @Inject constructor(
|
||||||
fun cardToJson(card: Card?): String {
|
fun cardToJson(card: Card?): String {
|
||||||
return moshi.adapter<Card?>().toJson(card)
|
return moshi.adapter<Card?>().toJson(card)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@TypeConverter
|
||||||
|
fun jsonToCard(cardJson: String?): Card? {
|
||||||
|
return cardJson?.let { moshi.adapter<Card?>().fromJson(cardJson) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@TypeConverter
|
||||||
|
fun stringListToJson(list: List<String>?): String? {
|
||||||
|
return moshi.adapter<List<String>?>().toJson(list)
|
||||||
|
}
|
||||||
|
|
||||||
|
@TypeConverter
|
||||||
|
fun jsonToStringList(listJson: String?): List<String>? {
|
||||||
|
return listJson?.let { moshi.adapter<List<String>?>().fromJson(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@TypeConverter
|
||||||
|
fun applicationToJson(application: Status.Application?): String {
|
||||||
|
return moshi.adapter<Status.Application?>().toJson(application)
|
||||||
|
}
|
||||||
|
|
||||||
|
@TypeConverter
|
||||||
|
fun jsonToApplication(applicationJson: String?): Status.Application? {
|
||||||
|
return applicationJson?.let { moshi.adapter<Status.Application?>().fromJson(it) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,66 @@
|
||||||
|
/* Copyright 2024 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.db
|
||||||
|
|
||||||
|
import androidx.room.withTransaction
|
||||||
|
import com.keylesspalace.tusky.db.entity.HomeTimelineEntity
|
||||||
|
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.db.entity.TimelineStatusEntity
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
class DatabaseCleaner @Inject constructor(
|
||||||
|
private val db: AppDatabase
|
||||||
|
) {
|
||||||
|
/**
|
||||||
|
* Cleans the [HomeTimelineEntity], [TimelineStatusEntity], [TimelineAccountEntity], [NotificationEntity] and [NotificationReportEntity] tables from old entries.
|
||||||
|
* Should be regularly run to prevent the database from growing too big.
|
||||||
|
* @param tuskyAccountId id of the account for which to clean tables
|
||||||
|
* @param timelineLimit how many timeline items to keep
|
||||||
|
* @param notificationLimit how many notifications to keep
|
||||||
|
*/
|
||||||
|
suspend fun cleanupOldData(
|
||||||
|
tuskyAccountId: Long,
|
||||||
|
timelineLimit: Int,
|
||||||
|
notificationLimit: Int
|
||||||
|
) {
|
||||||
|
db.withTransaction {
|
||||||
|
// the order here is important - foreign key constraints must not be violated
|
||||||
|
db.notificationsDao().cleanupNotifications(tuskyAccountId, notificationLimit)
|
||||||
|
db.notificationsDao().cleanupReports(tuskyAccountId)
|
||||||
|
db.timelineDao().cleanupHomeTimeline(tuskyAccountId, timelineLimit)
|
||||||
|
db.timelineStatusDao().cleanupStatuses(tuskyAccountId)
|
||||||
|
db.timelineAccountDao().cleanupAccounts(tuskyAccountId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deletes everything from the [HomeTimelineEntity], [TimelineStatusEntity], [TimelineAccountEntity], [NotificationEntity] and [NotificationReportEntity] tables for one user.
|
||||||
|
* Intended to be used when a user logs out.
|
||||||
|
* @param tuskyAccountId id of the account for which to clean tables
|
||||||
|
*/
|
||||||
|
suspend fun cleanupEverything(tuskyAccountId: Long) {
|
||||||
|
db.withTransaction {
|
||||||
|
// the order here is important - foreign key constraints must not be violated
|
||||||
|
db.notificationsDao().removeAllNotifications(tuskyAccountId)
|
||||||
|
db.notificationsDao().removeAllReports(tuskyAccountId)
|
||||||
|
db.timelineDao().removeAllHomeTimelineItems(tuskyAccountId)
|
||||||
|
db.timelineStatusDao().removeAllStatuses(tuskyAccountId)
|
||||||
|
db.timelineAccountDao().removeAllAccounts(tuskyAccountId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -24,6 +24,7 @@ import androidx.lifecycle.LifecycleOwner
|
||||||
import androidx.lifecycle.lifecycleScope
|
import androidx.lifecycle.lifecycleScope
|
||||||
import com.keylesspalace.tusky.R
|
import com.keylesspalace.tusky.R
|
||||||
import com.keylesspalace.tusky.components.drafts.DraftsActivity
|
import com.keylesspalace.tusky.components.drafts.DraftsActivity
|
||||||
|
import com.keylesspalace.tusky.db.dao.DraftDao
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
|
@ -1,346 +0,0 @@
|
||||||
/* 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.db
|
|
||||||
|
|
||||||
import androidx.paging.PagingSource
|
|
||||||
import androidx.room.Dao
|
|
||||||
import androidx.room.Insert
|
|
||||||
import androidx.room.OnConflictStrategy.Companion.REPLACE
|
|
||||||
import androidx.room.Query
|
|
||||||
import androidx.room.TypeConverters
|
|
||||||
import com.keylesspalace.tusky.entity.Attachment
|
|
||||||
import com.keylesspalace.tusky.entity.Card
|
|
||||||
import com.keylesspalace.tusky.entity.Emoji
|
|
||||||
import com.keylesspalace.tusky.entity.HashTag
|
|
||||||
import com.keylesspalace.tusky.entity.Poll
|
|
||||||
import com.keylesspalace.tusky.entity.Status
|
|
||||||
|
|
||||||
@Dao
|
|
||||||
abstract class TimelineDao {
|
|
||||||
|
|
||||||
@Insert(onConflict = REPLACE)
|
|
||||||
abstract suspend fun insertAccount(timelineAccountEntity: TimelineAccountEntity): Long
|
|
||||||
|
|
||||||
@Insert(onConflict = REPLACE)
|
|
||||||
abstract suspend fun insertStatus(timelineStatusEntity: TimelineStatusEntity): Long
|
|
||||||
|
|
||||||
@Query(
|
|
||||||
"""
|
|
||||||
SELECT s.serverId, s.url, s.timelineUserId,
|
|
||||||
s.authorServerId, s.inReplyToId, s.inReplyToAccountId, s.createdAt, s.editedAt,
|
|
||||||
s.emojis, s.reblogsCount, s.favouritesCount, s.repliesCount, s.reblogged, s.favourited, s.bookmarked, s.sensitive,
|
|
||||||
s.spoilerText, s.visibility, s.mentions, s.tags, s.application, s.reblogServerId,s.reblogAccountId,
|
|
||||||
s.content, s.attachments, s.poll, s.card, s.muted, s.expanded, s.contentShowing, s.contentCollapsed, s.pinned, s.language, s.filtered,
|
|
||||||
a.serverId as 'a_serverId', a.timelineUserId as 'a_timelineUserId',
|
|
||||||
a.localUsername as 'a_localUsername', a.username as 'a_username',
|
|
||||||
a.displayName as 'a_displayName', a.url as 'a_url', a.avatar as 'a_avatar',
|
|
||||||
a.emojis as 'a_emojis', a.bot as 'a_bot',
|
|
||||||
rb.serverId as 'rb_serverId', rb.timelineUserId 'rb_timelineUserId',
|
|
||||||
rb.localUsername as 'rb_localUsername', rb.username as 'rb_username',
|
|
||||||
rb.displayName as 'rb_displayName', rb.url as 'rb_url', rb.avatar as 'rb_avatar',
|
|
||||||
rb.emojis as 'rb_emojis', rb.bot as 'rb_bot'
|
|
||||||
FROM TimelineStatusEntity s
|
|
||||||
LEFT JOIN TimelineAccountEntity a ON (s.timelineUserId = a.timelineUserId AND s.authorServerId = a.serverId)
|
|
||||||
LEFT JOIN TimelineAccountEntity rb ON (s.timelineUserId = rb.timelineUserId AND s.reblogAccountId = rb.serverId)
|
|
||||||
WHERE s.timelineUserId = :account
|
|
||||||
ORDER BY LENGTH(s.serverId) DESC, s.serverId DESC"""
|
|
||||||
)
|
|
||||||
abstract fun getStatuses(account: Long): PagingSource<Int, TimelineStatusWithAccount>
|
|
||||||
|
|
||||||
@Query(
|
|
||||||
"""
|
|
||||||
SELECT s.serverId, s.url, s.timelineUserId,
|
|
||||||
s.authorServerId, s.inReplyToId, s.inReplyToAccountId, s.createdAt, s.editedAt,
|
|
||||||
s.emojis, s.reblogsCount, s.favouritesCount, s.repliesCount, s.reblogged, s.favourited, s.bookmarked, s.sensitive,
|
|
||||||
s.spoilerText, s.visibility, s.mentions, s.tags, s.application, s.reblogServerId,s.reblogAccountId,
|
|
||||||
s.content, s.attachments, s.poll, s.card, s.muted, s.expanded, s.contentShowing, s.contentCollapsed, s.pinned, s.language, s.filtered,
|
|
||||||
a.serverId as 'a_serverId', a.timelineUserId as 'a_timelineUserId',
|
|
||||||
a.localUsername as 'a_localUsername', a.username as 'a_username',
|
|
||||||
a.displayName as 'a_displayName', a.url as 'a_url', a.avatar as 'a_avatar',
|
|
||||||
a.emojis as 'a_emojis', a.bot as 'a_bot',
|
|
||||||
rb.serverId as 'rb_serverId', rb.timelineUserId 'rb_timelineUserId',
|
|
||||||
rb.localUsername as 'rb_localUsername', rb.username as 'rb_username',
|
|
||||||
rb.displayName as 'rb_displayName', rb.url as 'rb_url', rb.avatar as 'rb_avatar',
|
|
||||||
rb.emojis as 'rb_emojis', rb.bot as 'rb_bot'
|
|
||||||
FROM TimelineStatusEntity s
|
|
||||||
LEFT JOIN TimelineAccountEntity a ON (s.timelineUserId = a.timelineUserId AND s.authorServerId = a.serverId)
|
|
||||||
LEFT JOIN TimelineAccountEntity rb ON (s.timelineUserId = rb.timelineUserId AND s.reblogAccountId = rb.serverId)
|
|
||||||
WHERE (s.serverId = :statusId OR s.reblogServerId = :statusId)
|
|
||||||
AND s.authorServerId IS NOT NULL
|
|
||||||
AND s.timelineUserId = :accountId"""
|
|
||||||
)
|
|
||||||
abstract suspend fun getStatus(accountId: Long, statusId: String): TimelineStatusWithAccount?
|
|
||||||
|
|
||||||
@Query(
|
|
||||||
"""DELETE FROM TimelineStatusEntity WHERE timelineUserId = :accountId AND
|
|
||||||
(LENGTH(serverId) < LENGTH(:maxId) OR LENGTH(serverId) == LENGTH(:maxId) AND serverId <= :maxId)
|
|
||||||
AND
|
|
||||||
(LENGTH(serverId) > LENGTH(:minId) OR LENGTH(serverId) == LENGTH(:minId) AND serverId >= :minId)
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
abstract suspend fun deleteRange(accountId: Long, minId: String, maxId: String): Int
|
|
||||||
|
|
||||||
suspend fun update(accountId: Long, status: Status) {
|
|
||||||
update(
|
|
||||||
accountId = accountId,
|
|
||||||
statusId = status.id,
|
|
||||||
content = status.content,
|
|
||||||
editedAt = status.editedAt?.time,
|
|
||||||
emojis = status.emojis,
|
|
||||||
reblogsCount = status.reblogsCount,
|
|
||||||
favouritesCount = status.favouritesCount,
|
|
||||||
repliesCount = status.repliesCount,
|
|
||||||
reblogged = status.reblogged,
|
|
||||||
bookmarked = status.bookmarked,
|
|
||||||
favourited = status.favourited,
|
|
||||||
sensitive = status.sensitive,
|
|
||||||
spoilerText = status.spoilerText,
|
|
||||||
visibility = status.visibility,
|
|
||||||
attachments = status.attachments,
|
|
||||||
mentions = status.mentions,
|
|
||||||
tags = status.tags,
|
|
||||||
poll = status.poll,
|
|
||||||
muted = status.muted,
|
|
||||||
pinned = status.pinned,
|
|
||||||
card = status.card,
|
|
||||||
language = status.language
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Query(
|
|
||||||
"""UPDATE TimelineStatusEntity
|
|
||||||
SET content = :content,
|
|
||||||
editedAt = :editedAt,
|
|
||||||
emojis = :emojis,
|
|
||||||
reblogsCount = :reblogsCount,
|
|
||||||
favouritesCount = :favouritesCount,
|
|
||||||
repliesCount = :repliesCount,
|
|
||||||
reblogged = :reblogged,
|
|
||||||
bookmarked = :bookmarked,
|
|
||||||
favourited = :favourited,
|
|
||||||
sensitive = :sensitive,
|
|
||||||
spoilerText = :spoilerText,
|
|
||||||
visibility = :visibility,
|
|
||||||
attachments = :attachments,
|
|
||||||
mentions = :mentions,
|
|
||||||
tags = :tags,
|
|
||||||
poll = :poll,
|
|
||||||
muted = :muted,
|
|
||||||
pinned = :pinned,
|
|
||||||
card = :card,
|
|
||||||
language = :language
|
|
||||||
WHERE timelineUserId = :accountId AND (serverId = :statusId OR reblogServerId = :statusId)"""
|
|
||||||
)
|
|
||||||
@TypeConverters(Converters::class)
|
|
||||||
protected abstract suspend fun update(
|
|
||||||
accountId: Long,
|
|
||||||
statusId: String,
|
|
||||||
content: String?,
|
|
||||||
editedAt: Long?,
|
|
||||||
emojis: List<Emoji>,
|
|
||||||
reblogsCount: Int,
|
|
||||||
favouritesCount: Int,
|
|
||||||
repliesCount: Int,
|
|
||||||
reblogged: Boolean,
|
|
||||||
bookmarked: Boolean,
|
|
||||||
favourited: Boolean,
|
|
||||||
sensitive: Boolean,
|
|
||||||
spoilerText: String,
|
|
||||||
visibility: Status.Visibility,
|
|
||||||
attachments: List<Attachment>,
|
|
||||||
mentions: List<Status.Mention>,
|
|
||||||
tags: List<HashTag>?,
|
|
||||||
poll: Poll?,
|
|
||||||
muted: Boolean?,
|
|
||||||
pinned: Boolean,
|
|
||||||
card: Card?,
|
|
||||||
language: String?
|
|
||||||
)
|
|
||||||
|
|
||||||
@Query(
|
|
||||||
"""UPDATE TimelineStatusEntity SET bookmarked = :bookmarked
|
|
||||||
WHERE timelineUserId = :accountId AND (serverId = :statusId OR reblogServerId = :statusId)"""
|
|
||||||
)
|
|
||||||
abstract suspend fun setBookmarked(accountId: Long, statusId: String, bookmarked: Boolean)
|
|
||||||
|
|
||||||
@Query(
|
|
||||||
"""UPDATE TimelineStatusEntity SET reblogged = :reblogged
|
|
||||||
WHERE timelineUserId = :accountId AND (serverId = :statusId OR reblogServerId = :statusId)"""
|
|
||||||
)
|
|
||||||
abstract suspend fun setReblogged(accountId: Long, statusId: String, reblogged: Boolean)
|
|
||||||
|
|
||||||
@Query(
|
|
||||||
"""DELETE FROM TimelineStatusEntity WHERE timelineUserId = :accountId AND
|
|
||||||
(authorServerId = :userId OR reblogAccountId = :userId)"""
|
|
||||||
)
|
|
||||||
abstract suspend fun removeAllByUser(accountId: Long, userId: String)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Removes everything in the TimelineStatusEntity and TimelineAccountEntity tables for one user account
|
|
||||||
* @param accountId id of the account for which to clean tables
|
|
||||||
*/
|
|
||||||
suspend fun removeAll(accountId: Long) {
|
|
||||||
removeAllStatuses(accountId)
|
|
||||||
removeAllAccounts(accountId)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Query("DELETE FROM TimelineStatusEntity WHERE timelineUserId = :accountId")
|
|
||||||
abstract suspend fun removeAllStatuses(accountId: Long)
|
|
||||||
|
|
||||||
@Query("DELETE FROM TimelineAccountEntity WHERE timelineUserId = :accountId")
|
|
||||||
abstract suspend fun removeAllAccounts(accountId: Long)
|
|
||||||
|
|
||||||
@Query(
|
|
||||||
"""DELETE FROM TimelineStatusEntity WHERE timelineUserId = :accountId
|
|
||||||
AND serverId = :statusId"""
|
|
||||||
)
|
|
||||||
abstract suspend fun delete(accountId: Long, statusId: String)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Cleans the TimelineStatusEntity and TimelineAccountEntity tables from old entries.
|
|
||||||
* @param accountId id of the account for which to clean tables
|
|
||||||
* @param limit how many statuses to keep
|
|
||||||
*/
|
|
||||||
suspend fun cleanup(accountId: Long, limit: Int) {
|
|
||||||
cleanupStatuses(accountId, limit)
|
|
||||||
cleanupAccounts(accountId)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Cleans the TimelineStatusEntity table from old status entries.
|
|
||||||
* @param accountId id of the account for which to clean statuses
|
|
||||||
* @param limit how many statuses to keep
|
|
||||||
*/
|
|
||||||
@Query(
|
|
||||||
"""DELETE FROM TimelineStatusEntity WHERE timelineUserId = :accountId AND serverId NOT IN
|
|
||||||
(SELECT serverId FROM TimelineStatusEntity WHERE timelineUserId = :accountId ORDER BY LENGTH(serverId) DESC, serverId DESC LIMIT :limit)
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
abstract suspend fun cleanupStatuses(accountId: Long, limit: Int)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Cleans the TimelineAccountEntity table from accounts that are no longer referenced in the TimelineStatusEntity table
|
|
||||||
* @param accountId id of the user account for which to clean timeline accounts
|
|
||||||
*/
|
|
||||||
@Query(
|
|
||||||
"""DELETE FROM TimelineAccountEntity WHERE timelineUserId = :accountId AND serverId NOT IN
|
|
||||||
(SELECT authorServerId FROM TimelineStatusEntity WHERE timelineUserId = :accountId)
|
|
||||||
AND serverId NOT IN
|
|
||||||
(SELECT reblogAccountId FROM TimelineStatusEntity WHERE timelineUserId = :accountId AND reblogAccountId IS NOT NULL)"""
|
|
||||||
)
|
|
||||||
abstract suspend fun cleanupAccounts(accountId: Long)
|
|
||||||
|
|
||||||
@Query(
|
|
||||||
"""UPDATE TimelineStatusEntity SET poll = :poll
|
|
||||||
WHERE timelineUserId = :accountId AND (serverId = :statusId OR reblogServerId = :statusId)"""
|
|
||||||
)
|
|
||||||
@TypeConverters(Converters::class)
|
|
||||||
abstract suspend fun setVoted(accountId: Long, statusId: String, poll: Poll)
|
|
||||||
|
|
||||||
@Query(
|
|
||||||
"""UPDATE TimelineStatusEntity SET expanded = :expanded
|
|
||||||
WHERE timelineUserId = :accountId AND (serverId = :statusId OR reblogServerId = :statusId)"""
|
|
||||||
)
|
|
||||||
abstract suspend fun setExpanded(accountId: Long, statusId: String, expanded: Boolean)
|
|
||||||
|
|
||||||
@Query(
|
|
||||||
"""UPDATE TimelineStatusEntity SET contentShowing = :contentShowing
|
|
||||||
WHERE timelineUserId = :accountId AND (serverId = :statusId OR reblogServerId = :statusId)"""
|
|
||||||
)
|
|
||||||
abstract suspend fun setContentShowing(
|
|
||||||
accountId: Long,
|
|
||||||
statusId: String,
|
|
||||||
contentShowing: Boolean
|
|
||||||
)
|
|
||||||
|
|
||||||
@Query(
|
|
||||||
"""UPDATE TimelineStatusEntity SET contentCollapsed = :contentCollapsed
|
|
||||||
WHERE timelineUserId = :accountId AND (serverId = :statusId OR reblogServerId = :statusId)"""
|
|
||||||
)
|
|
||||||
abstract suspend fun setContentCollapsed(
|
|
||||||
accountId: Long,
|
|
||||||
statusId: String,
|
|
||||||
contentCollapsed: Boolean
|
|
||||||
)
|
|
||||||
|
|
||||||
@Query(
|
|
||||||
"""UPDATE TimelineStatusEntity SET pinned = :pinned
|
|
||||||
WHERE timelineUserId = :accountId AND (serverId = :statusId OR reblogServerId = :statusId)"""
|
|
||||||
)
|
|
||||||
abstract suspend fun setPinned(accountId: Long, statusId: String, pinned: Boolean)
|
|
||||||
|
|
||||||
@Query(
|
|
||||||
"""DELETE FROM TimelineStatusEntity
|
|
||||||
WHERE timelineUserId = :accountId AND authorServerId IN (
|
|
||||||
SELECT serverId FROM TimelineAccountEntity WHERE username LIKE '%@' || :instanceDomain
|
|
||||||
AND timelineUserId = :accountId
|
|
||||||
)"""
|
|
||||||
)
|
|
||||||
abstract suspend fun deleteAllFromInstance(accountId: Long, instanceDomain: String)
|
|
||||||
|
|
||||||
@Query(
|
|
||||||
"UPDATE TimelineStatusEntity SET filtered = NULL WHERE timelineUserId = :accountId AND (serverId = :statusId OR reblogServerId = :statusId)"
|
|
||||||
)
|
|
||||||
abstract suspend fun clearWarning(accountId: Long, statusId: String): Int
|
|
||||||
|
|
||||||
@Query(
|
|
||||||
"SELECT serverId FROM TimelineStatusEntity WHERE timelineUserId = :accountId ORDER BY LENGTH(serverId) DESC, serverId DESC LIMIT 1"
|
|
||||||
)
|
|
||||||
abstract suspend fun getTopId(accountId: Long): String?
|
|
||||||
|
|
||||||
@Query(
|
|
||||||
"SELECT serverId FROM TimelineStatusEntity WHERE timelineUserId = :accountId AND authorServerId IS NULL ORDER BY LENGTH(serverId) DESC, serverId DESC LIMIT 1"
|
|
||||||
)
|
|
||||||
abstract suspend fun getTopPlaceholderId(accountId: Long): String?
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns the id directly above [serverId], or null if [serverId] is the id of the top status
|
|
||||||
*/
|
|
||||||
@Query(
|
|
||||||
"SELECT serverId FROM TimelineStatusEntity WHERE timelineUserId = :accountId AND (LENGTH(:serverId) < LENGTH(serverId) OR (LENGTH(:serverId) = LENGTH(serverId) AND :serverId < serverId)) ORDER BY LENGTH(serverId) ASC, serverId ASC LIMIT 1"
|
|
||||||
)
|
|
||||||
abstract suspend fun getIdAbove(accountId: Long, serverId: String): String?
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns the ID directly below [serverId], or null if [serverId] is the ID of the bottom
|
|
||||||
* status
|
|
||||||
*/
|
|
||||||
@Query(
|
|
||||||
"SELECT serverId FROM TimelineStatusEntity WHERE timelineUserId = :accountId AND (LENGTH(:serverId) > LENGTH(serverId) OR (LENGTH(:serverId) = LENGTH(serverId) AND :serverId > serverId)) ORDER BY LENGTH(serverId) DESC, serverId DESC LIMIT 1"
|
|
||||||
)
|
|
||||||
abstract suspend fun getIdBelow(accountId: Long, serverId: String): String?
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns the id of the next placeholder after [serverId]
|
|
||||||
*/
|
|
||||||
@Query(
|
|
||||||
"SELECT serverId FROM TimelineStatusEntity WHERE timelineUserId = :accountId AND authorServerId IS NULL AND (LENGTH(:serverId) > LENGTH(serverId) OR (LENGTH(:serverId) = LENGTH(serverId) AND :serverId > serverId)) ORDER BY LENGTH(serverId) DESC, serverId DESC LIMIT 1"
|
|
||||||
)
|
|
||||||
abstract suspend fun getNextPlaceholderIdAfter(accountId: Long, serverId: String): String?
|
|
||||||
|
|
||||||
@Query("SELECT COUNT(*) FROM TimelineStatusEntity WHERE timelineUserId = :accountId")
|
|
||||||
abstract suspend fun getStatusCount(accountId: Long): Int
|
|
||||||
|
|
||||||
/** Developer tools: Find N most recent status IDs */
|
|
||||||
@Query(
|
|
||||||
"SELECT serverId FROM TimelineStatusEntity WHERE timelineUserId = :accountId ORDER BY LENGTH(serverId) DESC, serverId DESC LIMIT :count"
|
|
||||||
)
|
|
||||||
abstract suspend fun getMostRecentNStatusIds(accountId: Long, count: Int): List<String>
|
|
||||||
|
|
||||||
/** Developer tools: Convert a status to a placeholder */
|
|
||||||
@Query("UPDATE TimelineStatusEntity SET authorServerId = NULL WHERE serverId = :serverId")
|
|
||||||
abstract suspend fun convertStatustoPlaceholder(serverId: String)
|
|
||||||
}
|
|
|
@ -13,13 +13,14 @@
|
||||||
* You should have received a copy of the GNU General Public License along with Tusky; if not,
|
* You should have received a copy of the GNU General Public License along with Tusky; if not,
|
||||||
* see <http://www.gnu.org/licenses>. */
|
* see <http://www.gnu.org/licenses>. */
|
||||||
|
|
||||||
package com.keylesspalace.tusky.db
|
package com.keylesspalace.tusky.db.dao
|
||||||
|
|
||||||
import androidx.room.Dao
|
import androidx.room.Dao
|
||||||
import androidx.room.Delete
|
import androidx.room.Delete
|
||||||
import androidx.room.Insert
|
import androidx.room.Insert
|
||||||
import androidx.room.OnConflictStrategy
|
import androidx.room.OnConflictStrategy
|
||||||
import androidx.room.Query
|
import androidx.room.Query
|
||||||
|
import com.keylesspalace.tusky.db.entity.AccountEntity
|
||||||
|
|
||||||
@Dao
|
@Dao
|
||||||
interface AccountDao {
|
interface AccountDao {
|
|
@ -13,13 +13,14 @@
|
||||||
* You should have received a copy of the GNU General Public License along with Tusky; if not,
|
* You should have received a copy of the GNU General Public License along with Tusky; if not,
|
||||||
* see <http://www.gnu.org/licenses>. */
|
* see <http://www.gnu.org/licenses>. */
|
||||||
|
|
||||||
package com.keylesspalace.tusky.db
|
package com.keylesspalace.tusky.db.dao
|
||||||
|
|
||||||
import androidx.paging.PagingSource
|
import androidx.paging.PagingSource
|
||||||
import androidx.room.Dao
|
import androidx.room.Dao
|
||||||
import androidx.room.Insert
|
import androidx.room.Insert
|
||||||
import androidx.room.OnConflictStrategy
|
import androidx.room.OnConflictStrategy
|
||||||
import androidx.room.Query
|
import androidx.room.Query
|
||||||
|
import com.keylesspalace.tusky.db.entity.DraftEntity
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
|
||||||
@Dao
|
@Dao
|
|
@ -13,12 +13,15 @@
|
||||||
* You should have received a copy of the GNU General Public License along with Tusky; if not,
|
* You should have received a copy of the GNU General Public License along with Tusky; if not,
|
||||||
* see <http://www.gnu.org/licenses>. */
|
* see <http://www.gnu.org/licenses>. */
|
||||||
|
|
||||||
package com.keylesspalace.tusky.db
|
package com.keylesspalace.tusky.db.dao
|
||||||
|
|
||||||
import androidx.room.Dao
|
import androidx.room.Dao
|
||||||
import androidx.room.Query
|
import androidx.room.Query
|
||||||
import androidx.room.RewriteQueriesToDropUnusedColumns
|
import androidx.room.RewriteQueriesToDropUnusedColumns
|
||||||
import androidx.room.Upsert
|
import androidx.room.Upsert
|
||||||
|
import com.keylesspalace.tusky.db.entity.EmojisEntity
|
||||||
|
import com.keylesspalace.tusky.db.entity.InstanceEntity
|
||||||
|
import com.keylesspalace.tusky.db.entity.InstanceInfoEntity
|
||||||
|
|
||||||
@Dao
|
@Dao
|
||||||
interface InstanceDao {
|
interface InstanceDao {
|
|
@ -0,0 +1,175 @@
|
||||||
|
/* Copyright 2023 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.db.dao
|
||||||
|
|
||||||
|
import androidx.paging.PagingSource
|
||||||
|
import androidx.room.Dao
|
||||||
|
import androidx.room.Insert
|
||||||
|
import androidx.room.OnConflictStrategy.Companion.REPLACE
|
||||||
|
import androidx.room.Query
|
||||||
|
import com.keylesspalace.tusky.db.entity.NotificationDataEntity
|
||||||
|
import com.keylesspalace.tusky.db.entity.NotificationEntity
|
||||||
|
import com.keylesspalace.tusky.db.entity.NotificationReportEntity
|
||||||
|
|
||||||
|
@Dao
|
||||||
|
abstract class NotificationsDao {
|
||||||
|
|
||||||
|
@Insert(onConflict = REPLACE)
|
||||||
|
abstract suspend fun insertNotification(notificationEntity: NotificationEntity): Long
|
||||||
|
|
||||||
|
@Insert(onConflict = REPLACE)
|
||||||
|
abstract suspend fun insertReport(notificationReportDataEntity: NotificationReportEntity): Long
|
||||||
|
|
||||||
|
@Query(
|
||||||
|
"""
|
||||||
|
SELECT n.tuskyAccountId, n.type, n.id, n.loading,
|
||||||
|
a.serverId as 'a_serverId', a.tuskyAccountId as 'a_tuskyAccountId',
|
||||||
|
a.localUsername as 'a_localUsername', a.username as 'a_username',
|
||||||
|
a.displayName as 'a_displayName', a.url as 'a_url', a.avatar as 'a_avatar',
|
||||||
|
a.emojis as 'a_emojis', a.bot as 'a_bot',
|
||||||
|
s.serverId as 's_serverId', s.url as 's_url', s.tuskyAccountId as 's_tuskyAccountId',
|
||||||
|
s.authorServerId as 's_authorServerId', s.inReplyToId as 's_inReplyToId', s.inReplyToAccountId as 's_inReplyToAccountId',
|
||||||
|
s.content as 's_content', s.createdAt as 's_createdAt', s.editedAt as 's_editedAt', s.emojis as 's_emojis', s.reblogsCount as 's_reblogsCount',
|
||||||
|
s.favouritesCount as 's_favouritesCount', s.repliesCount as 's_repliesCount', s.reblogged as 's_reblogged', s.favourited as 's_favourited',
|
||||||
|
s.bookmarked as 's_bookmarked', s.sensitive as 's_sensitive', s.spoilerText as 's_spoilerText', s.visibility as 's_visibility',
|
||||||
|
s.mentions as 's_mentions', s.tags as 's_tags', s.application as 's_application', s.content as 's_content', s.attachments as 's_attachments', s.poll as 's_poll',
|
||||||
|
s.card as 's_card', s.muted as 's_muted', s.expanded as 's_expanded', s.contentShowing as 's_contentShowing', s.contentCollapsed as 's_contentCollapsed',
|
||||||
|
s.pinned as 's_pinned', s.language as 's_language', s.filtered as 's_filtered',
|
||||||
|
sa.serverId as 'sa_serverId', sa.tuskyAccountId as 'sa_tuskyAccountId',
|
||||||
|
sa.localUsername as 'sa_localUsername', sa.username as 'sa_username',
|
||||||
|
sa.displayName as 'sa_displayName', sa.url as 'sa_url', sa.avatar as 'sa_avatar',
|
||||||
|
sa.emojis as 'sa_emojis', sa.bot as 'sa_bot',
|
||||||
|
r.serverId as 'r_serverId', r.tuskyAccountId as 'r_tuskyAccountId',
|
||||||
|
r.category as 'r_category', r.statusIds as 'r_statusIds',
|
||||||
|
r.createdAt as 'r_createdAt', r.targetAccountId as 'r_targetAccountId',
|
||||||
|
ra.serverId as 'ra_serverId', ra.tuskyAccountId as 'ra_tuskyAccountId',
|
||||||
|
ra.localUsername as 'ra_localUsername', ra.username as 'ra_username',
|
||||||
|
ra.displayName as 'ra_displayName', ra.url as 'ra_url', ra.avatar as 'ra_avatar',
|
||||||
|
ra.emojis as 'ra_emojis', ra.bot as 'ra_bot'
|
||||||
|
FROM NotificationEntity n
|
||||||
|
LEFT JOIN TimelineAccountEntity a ON (n.tuskyAccountId = a.tuskyAccountId AND n.accountId = a.serverId)
|
||||||
|
LEFT JOIN TimelineStatusEntity s ON (n.tuskyAccountId = s.tuskyAccountId AND n.statusId = s.serverId)
|
||||||
|
LEFT JOIN TimelineAccountEntity sa ON (n.tuskyAccountId = sa.tuskyAccountId AND s.authorServerId = sa.serverId)
|
||||||
|
LEFT JOIN NotificationReportEntity r ON (n.tuskyAccountId = r.tuskyAccountId AND n.reportId = r.serverId)
|
||||||
|
LEFT JOIN TimelineAccountEntity ra ON (n.tuskyAccountId = ra.tuskyAccountId AND r.targetAccountId = ra.serverId)
|
||||||
|
WHERE n.tuskyAccountId = :tuskyAccountId
|
||||||
|
ORDER BY LENGTH(n.id) DESC, n.id DESC"""
|
||||||
|
)
|
||||||
|
abstract fun getNotifications(tuskyAccountId: Long): PagingSource<Int, NotificationDataEntity>
|
||||||
|
|
||||||
|
@Query(
|
||||||
|
"""DELETE FROM NotificationEntity WHERE tuskyAccountId = :tuskyAccountId AND id = :notificationId"""
|
||||||
|
)
|
||||||
|
abstract suspend fun delete(tuskyAccountId: Long, notificationId: String): Int
|
||||||
|
|
||||||
|
@Query(
|
||||||
|
"""DELETE FROM NotificationEntity WHERE tuskyAccountId = :tuskyAccountId AND
|
||||||
|
(LENGTH(id) < LENGTH(:maxId) OR LENGTH(id) == LENGTH(:maxId) AND id <= :maxId)
|
||||||
|
AND
|
||||||
|
(LENGTH(id) > LENGTH(:minId) OR LENGTH(id) == LENGTH(:minId) AND id >= :minId)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
abstract suspend fun deleteRange(tuskyAccountId: Long, minId: String, maxId: String): Int
|
||||||
|
|
||||||
|
@Query(
|
||||||
|
"""DELETE FROM NotificationEntity WHERE tuskyAccountId = :tuskyAccountId"""
|
||||||
|
)
|
||||||
|
internal abstract suspend fun removeAllNotifications(tuskyAccountId: Long)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deletes all NotificationReportEntities for Tusky user with id [tuskyAccountId].
|
||||||
|
* Warning: This can violate foreign key constraints if reports are still referenced in the NotificationEntity table.
|
||||||
|
*/
|
||||||
|
@Query(
|
||||||
|
"""DELETE FROM NotificationReportEntity WHERE tuskyAccountId = :tuskyAccountId"""
|
||||||
|
)
|
||||||
|
internal abstract suspend fun removeAllReports(tuskyAccountId: Long)
|
||||||
|
|
||||||
|
@Query(
|
||||||
|
"""DELETE FROM NotificationEntity WHERE tuskyAccountId = :tuskyAccountId AND statusId = :statusId"""
|
||||||
|
)
|
||||||
|
abstract suspend fun deleteAllWithStatus(tuskyAccountId: Long, statusId: String)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove all notifications from user with id [userId] unless they are admin notifications.
|
||||||
|
*/
|
||||||
|
@Query(
|
||||||
|
"""DELETE FROM NotificationEntity WHERE tuskyAccountId = :tuskyAccountId AND
|
||||||
|
statusId IN
|
||||||
|
(SELECT serverId FROM TimelineStatusEntity WHERE tuskyAccountId = :tuskyAccountId AND
|
||||||
|
(authorServerId == :userId OR accountId == :userId))
|
||||||
|
AND type != "admin.sign_up" AND type != "admin.report"
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
abstract suspend fun removeAllByUser(tuskyAccountId: Long, userId: String)
|
||||||
|
|
||||||
|
@Query(
|
||||||
|
"""DELETE FROM NotificationEntity
|
||||||
|
WHERE tuskyAccountId = :tuskyAccountId AND statusId IN (
|
||||||
|
SELECT serverId FROM TimelineStatusEntity WHERE tuskyAccountId = :tuskyAccountId AND authorServerId in
|
||||||
|
( SELECT serverId FROM TimelineAccountEntity WHERE username LIKE '%@' || :instanceDomain
|
||||||
|
AND tuskyAccountId = :tuskyAccountId)
|
||||||
|
OR accountId IN ( SELECT serverId FROM TimelineAccountEntity WHERE username LIKE '%@' || :instanceDomain
|
||||||
|
AND tuskyAccountId = :tuskyAccountId)
|
||||||
|
)"""
|
||||||
|
)
|
||||||
|
abstract suspend fun deleteAllFromInstance(tuskyAccountId: Long, instanceDomain: String)
|
||||||
|
|
||||||
|
@Query("SELECT id FROM NotificationEntity WHERE tuskyAccountId = :accountId ORDER BY LENGTH(id) DESC, id DESC LIMIT 1")
|
||||||
|
abstract suspend fun getTopId(accountId: Long): String?
|
||||||
|
|
||||||
|
@Query("SELECT id FROM NotificationEntity WHERE tuskyAccountId = :accountId AND type IS NULL ORDER BY LENGTH(id) DESC, id DESC LIMIT 1")
|
||||||
|
abstract suspend fun getTopPlaceholderId(accountId: Long): String?
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cleans the NotificationEntity table from old entries.
|
||||||
|
* @param tuskyAccountId id of the account for which to clean tables
|
||||||
|
* @param limit how many timeline items to keep
|
||||||
|
*/
|
||||||
|
@Query(
|
||||||
|
"""DELETE FROM NotificationEntity WHERE tuskyAccountId = :tuskyAccountId AND id NOT IN
|
||||||
|
(SELECT id FROM NotificationEntity WHERE tuskyAccountId = :tuskyAccountId ORDER BY LENGTH(id) DESC, id DESC LIMIT :limit)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
internal abstract suspend fun cleanupNotifications(tuskyAccountId: Long, limit: Int)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cleans the NotificationReportEntity table from unreferenced entries.
|
||||||
|
* @param tuskyAccountId id of the account for which to clean the table
|
||||||
|
*/
|
||||||
|
@Query(
|
||||||
|
"""DELETE FROM NotificationReportEntity WHERE tuskyAccountId = :tuskyAccountId
|
||||||
|
AND serverId NOT IN
|
||||||
|
(SELECT reportId FROM NotificationEntity WHERE tuskyAccountId = :tuskyAccountId and reportId IS NOT NULL)"""
|
||||||
|
)
|
||||||
|
internal abstract suspend fun cleanupReports(tuskyAccountId: Long)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the id directly above [id], or null if [id] is the id of the top item
|
||||||
|
*/
|
||||||
|
@Query(
|
||||||
|
"SELECT id FROM NotificationEntity WHERE tuskyAccountId = :tuskyAccountId AND (LENGTH(:id) < LENGTH(id) OR (LENGTH(:id) = LENGTH(id) AND :id < id)) ORDER BY LENGTH(id) ASC, id ASC LIMIT 1"
|
||||||
|
)
|
||||||
|
abstract suspend fun getIdAbove(tuskyAccountId: Long, id: String): String?
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the ID directly below [id], or null if [id] is the ID of the bottom item
|
||||||
|
*/
|
||||||
|
@Query(
|
||||||
|
"SELECT id FROM NotificationEntity WHERE tuskyAccountId = :tuskyAccountId AND (LENGTH(:id) > LENGTH(id) OR (LENGTH(:id) = LENGTH(id) AND :id > id)) ORDER BY LENGTH(id) DESC, id DESC LIMIT 1"
|
||||||
|
)
|
||||||
|
abstract suspend fun getIdBelow(tuskyAccountId: Long, id: String): String?
|
||||||
|
}
|
|
@ -0,0 +1,56 @@
|
||||||
|
/* Copyright 2024 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.db.dao
|
||||||
|
|
||||||
|
import androidx.room.Dao
|
||||||
|
import androidx.room.Insert
|
||||||
|
import androidx.room.OnConflictStrategy.Companion.REPLACE
|
||||||
|
import androidx.room.Query
|
||||||
|
import com.keylesspalace.tusky.db.entity.TimelineAccountEntity
|
||||||
|
|
||||||
|
@Dao
|
||||||
|
abstract class TimelineAccountDao {
|
||||||
|
|
||||||
|
@Insert(onConflict = REPLACE)
|
||||||
|
abstract suspend fun insert(timelineAccountEntity: TimelineAccountEntity): Long
|
||||||
|
|
||||||
|
@Query(
|
||||||
|
"""SELECT * FROM TimelineAccountEntity a
|
||||||
|
WHERE a.serverId = :accountId
|
||||||
|
AND a.tuskyAccountId = :tuskyAccountId"""
|
||||||
|
)
|
||||||
|
internal abstract suspend fun getAccount(tuskyAccountId: Long, accountId: String): TimelineAccountEntity?
|
||||||
|
|
||||||
|
@Query("DELETE FROM TimelineAccountEntity WHERE tuskyAccountId = :tuskyAccountId")
|
||||||
|
abstract suspend fun removeAllAccounts(tuskyAccountId: Long)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cleans the TimelineAccountEntity table from accounts that are no longer referenced by either TimelineStatusEntity, HomeTimelineEntity or NotificationEntity
|
||||||
|
* @param tuskyAccountId id of the user account for which to clean timeline accounts
|
||||||
|
*/
|
||||||
|
@Query(
|
||||||
|
"""DELETE FROM TimelineAccountEntity WHERE tuskyAccountId = :tuskyAccountId
|
||||||
|
AND serverId NOT IN
|
||||||
|
(SELECT authorServerId FROM TimelineStatusEntity WHERE tuskyAccountId = :tuskyAccountId)
|
||||||
|
AND serverId NOT IN
|
||||||
|
(SELECT reblogAccountId FROM HomeTimelineEntity WHERE tuskyAccountId = :tuskyAccountId AND reblogAccountId IS NOT NULL)
|
||||||
|
AND serverId NOT IN
|
||||||
|
(SELECT accountId FROM NotificationEntity WHERE tuskyAccountId = :tuskyAccountId AND accountId IS NOT NULL)
|
||||||
|
AND serverId NOT IN
|
||||||
|
(SELECT targetAccountId FROM NotificationReportEntity WHERE tuskyAccountId = :tuskyAccountId AND targetAccountId IS NOT NULL)"""
|
||||||
|
)
|
||||||
|
abstract suspend fun cleanupAccounts(tuskyAccountId: Long)
|
||||||
|
}
|
|
@ -0,0 +1,169 @@
|
||||||
|
/* 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.db.dao
|
||||||
|
|
||||||
|
import androidx.paging.PagingSource
|
||||||
|
import androidx.room.Dao
|
||||||
|
import androidx.room.Insert
|
||||||
|
import androidx.room.OnConflictStrategy.Companion.REPLACE
|
||||||
|
import androidx.room.Query
|
||||||
|
import com.keylesspalace.tusky.db.entity.HomeTimelineData
|
||||||
|
import com.keylesspalace.tusky.db.entity.HomeTimelineEntity
|
||||||
|
|
||||||
|
@Dao
|
||||||
|
abstract class TimelineDao {
|
||||||
|
|
||||||
|
@Insert(onConflict = REPLACE)
|
||||||
|
abstract suspend fun insertHomeTimelineItem(item: HomeTimelineEntity): Long
|
||||||
|
|
||||||
|
@Query(
|
||||||
|
"""
|
||||||
|
SELECT h.id, s.serverId, s.url, s.tuskyAccountId,
|
||||||
|
s.authorServerId, s.inReplyToId, s.inReplyToAccountId, s.createdAt, s.editedAt,
|
||||||
|
s.emojis, s.reblogsCount, s.favouritesCount, s.repliesCount, s.reblogged, s.favourited, s.bookmarked, s.sensitive,
|
||||||
|
s.spoilerText, s.visibility, s.mentions, s.tags, s.application,
|
||||||
|
s.content, s.attachments, s.poll, s.card, s.muted, s.expanded, s.contentShowing, s.contentCollapsed, s.pinned, s.language, s.filtered,
|
||||||
|
a.serverId as 'a_serverId', a.tuskyAccountId as 'a_tuskyAccountId',
|
||||||
|
a.localUsername as 'a_localUsername', a.username as 'a_username',
|
||||||
|
a.displayName as 'a_displayName', a.url as 'a_url', a.avatar as 'a_avatar',
|
||||||
|
a.emojis as 'a_emojis', a.bot as 'a_bot',
|
||||||
|
rb.serverId as 'rb_serverId', rb.tuskyAccountId 'rb_tuskyAccountId',
|
||||||
|
rb.localUsername as 'rb_localUsername', rb.username as 'rb_username',
|
||||||
|
rb.displayName as 'rb_displayName', rb.url as 'rb_url', rb.avatar as 'rb_avatar',
|
||||||
|
rb.emojis as 'rb_emojis', rb.bot as 'rb_bot',
|
||||||
|
h.loading
|
||||||
|
FROM HomeTimelineEntity h
|
||||||
|
LEFT JOIN TimelineStatusEntity s ON (h.statusId = s.serverId AND s.tuskyAccountId = :tuskyAccountId)
|
||||||
|
LEFT JOIN TimelineAccountEntity a ON (s.authorServerId = a.serverId AND a.tuskyAccountId = :tuskyAccountId)
|
||||||
|
LEFT JOIN TimelineAccountEntity rb ON (h.reblogAccountId = rb.serverId AND rb.tuskyAccountId = :tuskyAccountId)
|
||||||
|
WHERE h.tuskyAccountId = :tuskyAccountId
|
||||||
|
ORDER BY LENGTH(h.id) DESC, h.id DESC"""
|
||||||
|
)
|
||||||
|
abstract fun getHomeTimeline(tuskyAccountId: Long): PagingSource<Int, HomeTimelineData>
|
||||||
|
|
||||||
|
@Query(
|
||||||
|
"""DELETE FROM HomeTimelineEntity WHERE tuskyAccountId = :tuskyAccountId AND
|
||||||
|
(LENGTH(id) < LENGTH(:maxId) OR LENGTH(id) == LENGTH(:maxId) AND id <= :maxId)
|
||||||
|
AND
|
||||||
|
(LENGTH(id) > LENGTH(:minId) OR LENGTH(id) == LENGTH(:minId) AND id >= :minId)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
abstract suspend fun deleteRange(tuskyAccountId: Long, minId: String, maxId: String): Int
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove all home timeline items that are statuses or reblogs by the user with id [userId], including reblogs from other people.
|
||||||
|
* (e.g. because user was blocked)
|
||||||
|
*/
|
||||||
|
@Query(
|
||||||
|
"""DELETE FROM HomeTimelineEntity WHERE tuskyAccountId = :tuskyAccountId AND
|
||||||
|
(statusId IN
|
||||||
|
(SELECT serverId FROM TimelineStatusEntity WHERE tuskyAccountId = :tuskyAccountId AND authorServerId == :userId)
|
||||||
|
OR reblogAccountId == :userId)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
abstract suspend fun removeAllByUser(tuskyAccountId: Long, userId: String)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove all home timeline items that are statuses or reblogs by the user with id [userId], but not reblogs from other users.
|
||||||
|
* (e.g. because user was unfollowed)
|
||||||
|
*/
|
||||||
|
@Query(
|
||||||
|
"""DELETE FROM HomeTimelineEntity WHERE tuskyAccountId = :tuskyAccountId AND
|
||||||
|
((statusId IN
|
||||||
|
(SELECT serverId FROM TimelineStatusEntity WHERE tuskyAccountId = :tuskyAccountId AND authorServerId == :userId)
|
||||||
|
AND reblogAccountId IS NULL)
|
||||||
|
OR reblogAccountId == :userId)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
abstract suspend fun removeStatusesAndReblogsByUser(tuskyAccountId: Long, userId: String)
|
||||||
|
|
||||||
|
@Query("DELETE FROM HomeTimelineEntity WHERE tuskyAccountId = :tuskyAccountId")
|
||||||
|
abstract suspend fun removeAllHomeTimelineItems(tuskyAccountId: Long)
|
||||||
|
|
||||||
|
@Query(
|
||||||
|
"""DELETE FROM HomeTimelineEntity WHERE tuskyAccountId = :tuskyAccountId AND id = :id"""
|
||||||
|
)
|
||||||
|
abstract suspend fun deleteHomeTimelineItem(tuskyAccountId: Long, id: String)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deletes all hometimeline items that reference the status with it [statusId]. They can be regular statuses or reblogs.
|
||||||
|
*/
|
||||||
|
@Query(
|
||||||
|
"""DELETE FROM HomeTimelineEntity WHERE tuskyAccountId = :tuskyAccountId AND statusId = :statusId"""
|
||||||
|
)
|
||||||
|
abstract suspend fun deleteAllWithStatus(tuskyAccountId: Long, statusId: String)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trims the HomeTimelineEntity table down to [limit] entries by deleting the oldest in case there are more than [limit].
|
||||||
|
* @param tuskyAccountId id of the account for which to clean the home timeline
|
||||||
|
* @param limit how many timeline items to keep
|
||||||
|
*/
|
||||||
|
@Query(
|
||||||
|
"""DELETE FROM HomeTimelineEntity WHERE tuskyAccountId = :tuskyAccountId AND id NOT IN
|
||||||
|
(SELECT id FROM HomeTimelineEntity WHERE tuskyAccountId = :tuskyAccountId ORDER BY LENGTH(id) DESC, id DESC LIMIT :limit)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
internal abstract suspend fun cleanupHomeTimeline(tuskyAccountId: Long, limit: Int)
|
||||||
|
|
||||||
|
@Query(
|
||||||
|
"""DELETE FROM HomeTimelineEntity
|
||||||
|
WHERE tuskyAccountId = :tuskyAccountId AND statusId IN (
|
||||||
|
SELECT serverId FROM TimelineStatusEntity WHERE tuskyAccountId = :tuskyAccountId AND authorServerId in
|
||||||
|
( SELECT serverId FROM TimelineAccountEntity WHERE username LIKE '%@' || :instanceDomain
|
||||||
|
AND tuskyAccountId = :tuskyAccountId
|
||||||
|
))"""
|
||||||
|
)
|
||||||
|
abstract suspend fun deleteAllFromInstance(tuskyAccountId: Long, instanceDomain: String)
|
||||||
|
|
||||||
|
@Query(
|
||||||
|
"SELECT id FROM HomeTimelineEntity WHERE tuskyAccountId = :tuskyAccountId ORDER BY LENGTH(id) DESC, id DESC LIMIT 1"
|
||||||
|
)
|
||||||
|
abstract suspend fun getTopId(tuskyAccountId: Long): String?
|
||||||
|
|
||||||
|
@Query(
|
||||||
|
"SELECT id FROM HomeTimelineEntity WHERE tuskyAccountId = :tuskyAccountId AND statusId IS NULL ORDER BY LENGTH(id) DESC, id DESC LIMIT 1"
|
||||||
|
)
|
||||||
|
abstract suspend fun getTopPlaceholderId(tuskyAccountId: Long): String?
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the id directly above [id], or null if [id] is the id of the top item
|
||||||
|
*/
|
||||||
|
@Query(
|
||||||
|
"SELECT id FROM HomeTimelineEntity WHERE tuskyAccountId = :tuskyAccountId AND (LENGTH(:id) < LENGTH(id) OR (LENGTH(:id) = LENGTH(id) AND :id < id)) ORDER BY LENGTH(id) ASC, id ASC LIMIT 1"
|
||||||
|
)
|
||||||
|
abstract suspend fun getIdAbove(tuskyAccountId: Long, id: String): String?
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the ID directly below [id], or null if [id] is the ID of the bottom item
|
||||||
|
*/
|
||||||
|
@Query(
|
||||||
|
"SELECT id FROM HomeTimelineEntity WHERE tuskyAccountId = :tuskyAccountId AND (LENGTH(:id) > LENGTH(id) OR (LENGTH(:id) = LENGTH(id) AND :id > id)) ORDER BY LENGTH(id) DESC, id DESC LIMIT 1"
|
||||||
|
)
|
||||||
|
abstract suspend fun getIdBelow(tuskyAccountId: Long, id: String): String?
|
||||||
|
|
||||||
|
@Query("SELECT COUNT(*) FROM HomeTimelineEntity WHERE tuskyAccountId = :tuskyAccountId")
|
||||||
|
abstract suspend fun getHomeTimelineItemCount(tuskyAccountId: Long): Int
|
||||||
|
|
||||||
|
/** Developer tools: Find N most recent status IDs */
|
||||||
|
@Query(
|
||||||
|
"SELECT id FROM HomeTimelineEntity WHERE tuskyAccountId = :tuskyAccountId ORDER BY LENGTH(id) DESC, id DESC LIMIT :count"
|
||||||
|
)
|
||||||
|
abstract suspend fun getMostRecentNHomeTimelineIds(tuskyAccountId: Long, count: Int): List<String>
|
||||||
|
|
||||||
|
/** Developer tools: Convert a home timeline item to a placeholder */
|
||||||
|
@Query("UPDATE HomeTimelineEntity SET statusId = NULL, reblogAccountId = NULL WHERE id = :serverId")
|
||||||
|
abstract suspend fun convertHomeTimelineItemToPlaceholder(serverId: String)
|
||||||
|
}
|
|
@ -0,0 +1,279 @@
|
||||||
|
/* Copyright 2024 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.db.dao
|
||||||
|
|
||||||
|
import androidx.room.Dao
|
||||||
|
import androidx.room.Insert
|
||||||
|
import androidx.room.OnConflictStrategy.Companion.REPLACE
|
||||||
|
import androidx.room.Query
|
||||||
|
import androidx.room.Transaction
|
||||||
|
import androidx.room.TypeConverters
|
||||||
|
import com.keylesspalace.tusky.db.AppDatabase
|
||||||
|
import com.keylesspalace.tusky.db.Converters
|
||||||
|
import com.keylesspalace.tusky.db.entity.TimelineAccountEntity
|
||||||
|
import com.keylesspalace.tusky.db.entity.TimelineStatusEntity
|
||||||
|
import com.keylesspalace.tusky.entity.Attachment
|
||||||
|
import com.keylesspalace.tusky.entity.Card
|
||||||
|
import com.keylesspalace.tusky.entity.Emoji
|
||||||
|
import com.keylesspalace.tusky.entity.HashTag
|
||||||
|
import com.keylesspalace.tusky.entity.Poll
|
||||||
|
import com.keylesspalace.tusky.entity.Status
|
||||||
|
import com.squareup.moshi.Moshi
|
||||||
|
import com.squareup.moshi.adapter
|
||||||
|
|
||||||
|
@Dao
|
||||||
|
abstract class TimelineStatusDao(
|
||||||
|
private val db: AppDatabase
|
||||||
|
) {
|
||||||
|
|
||||||
|
@Insert(onConflict = REPLACE)
|
||||||
|
abstract suspend fun insert(timelineStatusEntity: TimelineStatusEntity): Long
|
||||||
|
|
||||||
|
@Transaction
|
||||||
|
open suspend fun getStatusWithAccount(tuskyAccountId: Long, statusId: String): Pair<TimelineStatusEntity, TimelineAccountEntity>? {
|
||||||
|
val status = getStatus(tuskyAccountId, statusId) ?: return null
|
||||||
|
val account = db.timelineAccountDao().getAccount(tuskyAccountId, status.authorServerId) ?: return null
|
||||||
|
return status to account
|
||||||
|
}
|
||||||
|
|
||||||
|
@Query(
|
||||||
|
"""
|
||||||
|
SELECT * FROM TimelineStatusEntity s
|
||||||
|
WHERE s.serverId = :statusId
|
||||||
|
AND s.authorServerId IS NOT NULL
|
||||||
|
AND s.tuskyAccountId = :tuskyAccountId"""
|
||||||
|
)
|
||||||
|
abstract suspend fun getStatus(tuskyAccountId: Long, statusId: String): TimelineStatusEntity?
|
||||||
|
|
||||||
|
@OptIn(ExperimentalStdlibApi::class)
|
||||||
|
suspend fun update(tuskyAccountId: Long, status: Status, moshi: Moshi) {
|
||||||
|
update(
|
||||||
|
tuskyAccountId = tuskyAccountId,
|
||||||
|
statusId = status.id,
|
||||||
|
content = status.content,
|
||||||
|
editedAt = status.editedAt?.time,
|
||||||
|
emojis = moshi.adapter<List<Emoji>?>().toJson(status.emojis),
|
||||||
|
reblogsCount = status.reblogsCount,
|
||||||
|
favouritesCount = status.favouritesCount,
|
||||||
|
repliesCount = status.repliesCount,
|
||||||
|
reblogged = status.reblogged,
|
||||||
|
bookmarked = status.bookmarked,
|
||||||
|
favourited = status.favourited,
|
||||||
|
sensitive = status.sensitive,
|
||||||
|
spoilerText = status.spoilerText,
|
||||||
|
visibility = status.visibility,
|
||||||
|
attachments = moshi.adapter<List<Attachment>?>().toJson(status.attachments),
|
||||||
|
mentions = moshi.adapter<List<Status.Mention>?>().toJson(status.mentions),
|
||||||
|
tags = moshi.adapter<List<HashTag>?>().toJson(status.tags),
|
||||||
|
poll = moshi.adapter<Poll?>().toJson(status.poll),
|
||||||
|
muted = status.muted,
|
||||||
|
pinned = status.pinned,
|
||||||
|
card = moshi.adapter<Card?>().toJson(status.card),
|
||||||
|
language = status.language
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Query(
|
||||||
|
"""UPDATE TimelineStatusEntity
|
||||||
|
SET content = :content,
|
||||||
|
editedAt = :editedAt,
|
||||||
|
emojis = :emojis,
|
||||||
|
reblogsCount = :reblogsCount,
|
||||||
|
favouritesCount = :favouritesCount,
|
||||||
|
repliesCount = :repliesCount,
|
||||||
|
reblogged = :reblogged,
|
||||||
|
bookmarked = :bookmarked,
|
||||||
|
favourited = :favourited,
|
||||||
|
sensitive = :sensitive,
|
||||||
|
spoilerText = :spoilerText,
|
||||||
|
visibility = :visibility,
|
||||||
|
attachments = :attachments,
|
||||||
|
mentions = :mentions,
|
||||||
|
tags = :tags,
|
||||||
|
poll = :poll,
|
||||||
|
muted = :muted,
|
||||||
|
pinned = :pinned,
|
||||||
|
card = :card,
|
||||||
|
language = :language
|
||||||
|
WHERE tuskyAccountId = :tuskyAccountId AND serverId = :statusId"""
|
||||||
|
)
|
||||||
|
@TypeConverters(Converters::class)
|
||||||
|
abstract suspend fun update(
|
||||||
|
tuskyAccountId: Long,
|
||||||
|
statusId: String,
|
||||||
|
content: String?,
|
||||||
|
editedAt: Long?,
|
||||||
|
emojis: String?,
|
||||||
|
reblogsCount: Int,
|
||||||
|
favouritesCount: Int,
|
||||||
|
repliesCount: Int,
|
||||||
|
reblogged: Boolean,
|
||||||
|
bookmarked: Boolean,
|
||||||
|
favourited: Boolean,
|
||||||
|
sensitive: Boolean,
|
||||||
|
spoilerText: String,
|
||||||
|
visibility: Status.Visibility,
|
||||||
|
attachments: String?,
|
||||||
|
mentions: String?,
|
||||||
|
tags: String?,
|
||||||
|
poll: String?,
|
||||||
|
muted: Boolean?,
|
||||||
|
pinned: Boolean,
|
||||||
|
card: String?,
|
||||||
|
language: String?
|
||||||
|
)
|
||||||
|
|
||||||
|
@Query(
|
||||||
|
"""UPDATE TimelineStatusEntity SET bookmarked = :bookmarked
|
||||||
|
WHERE tuskyAccountId = :tuskyAccountId AND serverId = :statusId"""
|
||||||
|
)
|
||||||
|
abstract suspend fun setBookmarked(tuskyAccountId: Long, statusId: String, bookmarked: Boolean)
|
||||||
|
|
||||||
|
@Query(
|
||||||
|
"""UPDATE TimelineStatusEntity SET reblogged = :reblogged
|
||||||
|
WHERE tuskyAccountId = :tuskyAccountId AND serverId = :statusId"""
|
||||||
|
)
|
||||||
|
abstract suspend fun setReblogged(tuskyAccountId: Long, statusId: String, reblogged: Boolean)
|
||||||
|
|
||||||
|
@Query("DELETE FROM TimelineStatusEntity WHERE tuskyAccountId = :tuskyAccountId")
|
||||||
|
abstract suspend fun removeAllStatuses(tuskyAccountId: Long)
|
||||||
|
|
||||||
|
@Query(
|
||||||
|
"""DELETE FROM HomeTimelineEntity WHERE tuskyAccountId = :tuskyAccountId AND id = :id"""
|
||||||
|
)
|
||||||
|
abstract suspend fun deleteHomeTimelineItem(tuskyAccountId: Long, id: String)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deletes all hometimeline items that reference the status with it [statusId]. They can be regular statuses or reblogs.
|
||||||
|
*/
|
||||||
|
@Query(
|
||||||
|
"""DELETE FROM HomeTimelineEntity WHERE tuskyAccountId = :tuskyAccountId AND statusId = :statusId"""
|
||||||
|
)
|
||||||
|
abstract suspend fun deleteAllWithStatus(tuskyAccountId: Long, statusId: String)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cleans the TimelineStatusEntity table from unreferenced status entries.
|
||||||
|
* @param tuskyAccountId id of the account for which to clean statuses
|
||||||
|
*/
|
||||||
|
@Query(
|
||||||
|
"""DELETE FROM TimelineStatusEntity WHERE tuskyAccountId = :tuskyAccountId
|
||||||
|
AND serverId NOT IN
|
||||||
|
(SELECT statusId FROM HomeTimelineEntity WHERE tuskyAccountId = :tuskyAccountId AND statusId IS NOT NULL)
|
||||||
|
AND serverId NOT IN
|
||||||
|
(SELECT statusId FROM NotificationEntity WHERE tuskyAccountId = :tuskyAccountId AND statusId IS NOT NULL)"""
|
||||||
|
)
|
||||||
|
internal abstract suspend fun cleanupStatuses(tuskyAccountId: Long)
|
||||||
|
|
||||||
|
@Query(
|
||||||
|
"""UPDATE TimelineStatusEntity SET poll = :poll
|
||||||
|
WHERE tuskyAccountId = :tuskyAccountId AND serverId = :statusId"""
|
||||||
|
)
|
||||||
|
abstract suspend fun setVoted(tuskyAccountId: Long, statusId: String, poll: String)
|
||||||
|
|
||||||
|
@Query(
|
||||||
|
"""UPDATE TimelineStatusEntity SET expanded = :expanded
|
||||||
|
WHERE tuskyAccountId = :tuskyAccountId AND serverId = :statusId"""
|
||||||
|
)
|
||||||
|
abstract suspend fun setExpanded(tuskyAccountId: Long, statusId: String, expanded: Boolean)
|
||||||
|
|
||||||
|
@Query(
|
||||||
|
"""UPDATE TimelineStatusEntity SET contentShowing = :contentShowing
|
||||||
|
WHERE tuskyAccountId = :tuskyAccountId AND serverId = :statusId"""
|
||||||
|
)
|
||||||
|
abstract suspend fun setContentShowing(
|
||||||
|
tuskyAccountId: Long,
|
||||||
|
statusId: String,
|
||||||
|
contentShowing: Boolean
|
||||||
|
)
|
||||||
|
|
||||||
|
@Query(
|
||||||
|
"""UPDATE TimelineStatusEntity SET contentCollapsed = :contentCollapsed
|
||||||
|
WHERE tuskyAccountId = :tuskyAccountId AND serverId = :statusId"""
|
||||||
|
)
|
||||||
|
abstract suspend fun setContentCollapsed(
|
||||||
|
tuskyAccountId: Long,
|
||||||
|
statusId: String,
|
||||||
|
contentCollapsed: Boolean
|
||||||
|
)
|
||||||
|
|
||||||
|
@Query(
|
||||||
|
"""UPDATE TimelineStatusEntity SET pinned = :pinned
|
||||||
|
WHERE tuskyAccountId = :tuskyAccountId AND serverId = :statusId"""
|
||||||
|
)
|
||||||
|
abstract suspend fun setPinned(tuskyAccountId: Long, statusId: String, pinned: Boolean)
|
||||||
|
|
||||||
|
@Query(
|
||||||
|
"""DELETE FROM HomeTimelineEntity
|
||||||
|
WHERE tuskyAccountId = :tuskyAccountId AND statusId IN (
|
||||||
|
SELECT serverId FROM TimelineStatusEntity WHERE tuskyAccountId = :tuskyAccountId AND authorServerId in
|
||||||
|
( SELECT serverId FROM TimelineAccountEntity WHERE username LIKE '%@' || :instanceDomain
|
||||||
|
AND tuskyAccountId = :tuskyAccountId
|
||||||
|
))"""
|
||||||
|
)
|
||||||
|
abstract suspend fun deleteAllFromInstance(tuskyAccountId: Long, instanceDomain: String)
|
||||||
|
|
||||||
|
@Query(
|
||||||
|
"UPDATE TimelineStatusEntity SET filtered = '[]' WHERE tuskyAccountId = :tuskyAccountId AND serverId = :statusId"
|
||||||
|
)
|
||||||
|
abstract suspend fun clearWarning(tuskyAccountId: Long, statusId: String): Int
|
||||||
|
|
||||||
|
@Query(
|
||||||
|
"SELECT id FROM HomeTimelineEntity WHERE tuskyAccountId = :tuskyAccountId ORDER BY LENGTH(id) DESC, id DESC LIMIT 1"
|
||||||
|
)
|
||||||
|
abstract suspend fun getTopId(tuskyAccountId: Long): String?
|
||||||
|
|
||||||
|
@Query(
|
||||||
|
"SELECT id FROM HomeTimelineEntity WHERE tuskyAccountId = :tuskyAccountId AND statusId IS NULL ORDER BY LENGTH(id) DESC, id DESC LIMIT 1"
|
||||||
|
)
|
||||||
|
abstract suspend fun getTopPlaceholderId(tuskyAccountId: Long): String?
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the id directly above [id], or null if [id] is the id of the top item
|
||||||
|
*/
|
||||||
|
@Query(
|
||||||
|
"SELECT id FROM HomeTimelineEntity WHERE tuskyAccountId = :tuskyAccountId AND (LENGTH(:id) < LENGTH(id) OR (LENGTH(:id) = LENGTH(id) AND :id < id)) ORDER BY LENGTH(id) ASC, id ASC LIMIT 1"
|
||||||
|
)
|
||||||
|
abstract suspend fun getIdAbove(tuskyAccountId: Long, id: String): String?
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the ID directly below [id], or null if [id] is the ID of the bottom item
|
||||||
|
*/
|
||||||
|
@Query(
|
||||||
|
"SELECT id FROM HomeTimelineEntity WHERE tuskyAccountId = :tuskyAccountId AND (LENGTH(:id) > LENGTH(id) OR (LENGTH(:id) = LENGTH(id) AND :id > id)) ORDER BY LENGTH(id) DESC, id DESC LIMIT 1"
|
||||||
|
)
|
||||||
|
abstract suspend fun getIdBelow(tuskyAccountId: Long, id: String): String?
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the id of the next placeholder after [id], or null if there is no placeholder.
|
||||||
|
*/
|
||||||
|
@Query(
|
||||||
|
"SELECT id FROM HomeTimelineEntity WHERE tuskyAccountId = :tuskyAccountId AND statusId IS NULL AND (LENGTH(:id) > LENGTH(id) OR (LENGTH(:id) = LENGTH(id) AND :id > id)) ORDER BY LENGTH(id) DESC, id DESC LIMIT 1"
|
||||||
|
)
|
||||||
|
abstract suspend fun getNextPlaceholderIdAfter(tuskyAccountId: Long, id: String): String?
|
||||||
|
|
||||||
|
@Query("SELECT COUNT(*) FROM HomeTimelineEntity WHERE tuskyAccountId = :tuskyAccountId")
|
||||||
|
abstract suspend fun getHomeTimelineItemCount(tuskyAccountId: Long): Int
|
||||||
|
|
||||||
|
/** Developer tools: Find N most recent status IDs */
|
||||||
|
@Query(
|
||||||
|
"SELECT id FROM HomeTimelineEntity WHERE tuskyAccountId = :tuskyAccountId ORDER BY LENGTH(id) DESC, id DESC LIMIT :count"
|
||||||
|
)
|
||||||
|
abstract suspend fun getMostRecentNStatusIds(tuskyAccountId: Long, count: Int): List<String>
|
||||||
|
|
||||||
|
/** Developer tools: Convert a home timeline item to a placeholder */
|
||||||
|
@Query("UPDATE HomeTimelineEntity SET statusId = NULL, reblogAccountId = NULL WHERE id = :serverId")
|
||||||
|
abstract suspend fun convertStatusToPlaceholder(serverId: String)
|
||||||
|
}
|
|
@ -13,7 +13,7 @@
|
||||||
* You should have received a copy of the GNU General Public License along with Tusky; if not,
|
* You should have received a copy of the GNU General Public License along with Tusky; if not,
|
||||||
* see <http://www.gnu.org/licenses>. */
|
* see <http://www.gnu.org/licenses>. */
|
||||||
|
|
||||||
package com.keylesspalace.tusky.db
|
package com.keylesspalace.tusky.db.entity
|
||||||
|
|
||||||
import androidx.room.ColumnInfo
|
import androidx.room.ColumnInfo
|
||||||
import androidx.room.Entity
|
import androidx.room.Entity
|
||||||
|
@ -21,6 +21,7 @@ import androidx.room.Index
|
||||||
import androidx.room.PrimaryKey
|
import androidx.room.PrimaryKey
|
||||||
import androidx.room.TypeConverters
|
import androidx.room.TypeConverters
|
||||||
import com.keylesspalace.tusky.TabData
|
import com.keylesspalace.tusky.TabData
|
||||||
|
import com.keylesspalace.tusky.db.Converters
|
||||||
import com.keylesspalace.tusky.defaultTabs
|
import com.keylesspalace.tusky.defaultTabs
|
||||||
import com.keylesspalace.tusky.entity.Emoji
|
import com.keylesspalace.tusky.entity.Emoji
|
||||||
import com.keylesspalace.tusky.entity.Status
|
import com.keylesspalace.tusky.entity.Status
|
|
@ -13,7 +13,7 @@
|
||||||
* You should have received a copy of the GNU General Public License along with Tusky; if not,
|
* You should have received a copy of the GNU General Public License along with Tusky; if not,
|
||||||
* see <http://www.gnu.org/licenses>. */
|
* see <http://www.gnu.org/licenses>. */
|
||||||
|
|
||||||
package com.keylesspalace.tusky.db
|
package com.keylesspalace.tusky.db.entity
|
||||||
|
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.os.Parcelable
|
import android.os.Parcelable
|
||||||
|
@ -21,6 +21,7 @@ import androidx.core.net.toUri
|
||||||
import androidx.room.Entity
|
import androidx.room.Entity
|
||||||
import androidx.room.PrimaryKey
|
import androidx.room.PrimaryKey
|
||||||
import androidx.room.TypeConverters
|
import androidx.room.TypeConverters
|
||||||
|
import com.keylesspalace.tusky.db.Converters
|
||||||
import com.keylesspalace.tusky.entity.Attachment
|
import com.keylesspalace.tusky.entity.Attachment
|
||||||
import com.keylesspalace.tusky.entity.NewPoll
|
import com.keylesspalace.tusky.entity.NewPoll
|
||||||
import com.keylesspalace.tusky.entity.Status
|
import com.keylesspalace.tusky.entity.Status
|
|
@ -0,0 +1,68 @@
|
||||||
|
/* Copyright 2024 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.db.entity
|
||||||
|
|
||||||
|
import androidx.room.Embedded
|
||||||
|
import androidx.room.Entity
|
||||||
|
import androidx.room.ForeignKey
|
||||||
|
import androidx.room.Index
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Entity to store an item on the home timeline. Can be a standalone status, a reblog, or a placeholder.
|
||||||
|
*/
|
||||||
|
@Entity(
|
||||||
|
primaryKeys = ["id", "tuskyAccountId"],
|
||||||
|
foreignKeys = (
|
||||||
|
[
|
||||||
|
ForeignKey(
|
||||||
|
entity = TimelineStatusEntity::class,
|
||||||
|
parentColumns = ["serverId", "tuskyAccountId"],
|
||||||
|
childColumns = ["statusId", "tuskyAccountId"]
|
||||||
|
),
|
||||||
|
ForeignKey(
|
||||||
|
entity = TimelineAccountEntity::class,
|
||||||
|
parentColumns = ["serverId", "tuskyAccountId"],
|
||||||
|
childColumns = ["reblogAccountId", "tuskyAccountId"]
|
||||||
|
)
|
||||||
|
]
|
||||||
|
),
|
||||||
|
indices = [
|
||||||
|
Index("statusId", "tuskyAccountId"),
|
||||||
|
Index("reblogAccountId", "tuskyAccountId"),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
data class HomeTimelineEntity(
|
||||||
|
val tuskyAccountId: Long,
|
||||||
|
// the id by which the timeline is sorted
|
||||||
|
val id: String,
|
||||||
|
// the id of the status, null when a placeholder
|
||||||
|
val statusId: String?,
|
||||||
|
// the id of the account who reblogged the status, null if no reblog
|
||||||
|
val reblogAccountId: String?,
|
||||||
|
// only relevant when this is a placeholder
|
||||||
|
val loading: Boolean = false
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper class for queries that return HomeTimelineEntity including all references
|
||||||
|
*/
|
||||||
|
data class HomeTimelineData(
|
||||||
|
val id: String,
|
||||||
|
@Embedded val status: TimelineStatusEntity?,
|
||||||
|
@Embedded(prefix = "a_") val account: TimelineAccountEntity?,
|
||||||
|
@Embedded(prefix = "rb_") val reblogAccount: TimelineAccountEntity?,
|
||||||
|
val loading: Boolean
|
||||||
|
)
|
|
@ -13,11 +13,12 @@
|
||||||
* You should have received a copy of the GNU General Public License along with Tusky; if not,
|
* You should have received a copy of the GNU General Public License along with Tusky; if not,
|
||||||
* see <http://www.gnu.org/licenses>. */
|
* see <http://www.gnu.org/licenses>. */
|
||||||
|
|
||||||
package com.keylesspalace.tusky.db
|
package com.keylesspalace.tusky.db.entity
|
||||||
|
|
||||||
import androidx.room.Entity
|
import androidx.room.Entity
|
||||||
import androidx.room.PrimaryKey
|
import androidx.room.PrimaryKey
|
||||||
import androidx.room.TypeConverters
|
import androidx.room.TypeConverters
|
||||||
|
import com.keylesspalace.tusky.db.Converters
|
||||||
import com.keylesspalace.tusky.entity.Emoji
|
import com.keylesspalace.tusky.entity.Emoji
|
||||||
|
|
||||||
@Entity
|
@Entity
|
|
@ -0,0 +1,107 @@
|
||||||
|
/* Copyright 2024 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.db.entity
|
||||||
|
|
||||||
|
import androidx.room.Embedded
|
||||||
|
import androidx.room.Entity
|
||||||
|
import androidx.room.ForeignKey
|
||||||
|
import androidx.room.Index
|
||||||
|
import androidx.room.TypeConverters
|
||||||
|
import com.keylesspalace.tusky.db.Converters
|
||||||
|
import com.keylesspalace.tusky.entity.Notification
|
||||||
|
import java.util.Date
|
||||||
|
|
||||||
|
data class NotificationDataEntity(
|
||||||
|
// id of the account logged into Tusky this notifications belongs to
|
||||||
|
val tuskyAccountId: Long,
|
||||||
|
// null when placeholder
|
||||||
|
val type: Notification.Type?,
|
||||||
|
val id: String,
|
||||||
|
@Embedded(prefix = "a_") val account: TimelineAccountEntity?,
|
||||||
|
@Embedded(prefix = "s_") val status: TimelineStatusEntity?,
|
||||||
|
@Embedded(prefix = "sa_") val statusAccount: TimelineAccountEntity?,
|
||||||
|
@Embedded(prefix = "r_") val report: NotificationReportEntity?,
|
||||||
|
@Embedded(prefix = "ra_") val reportTargetAccount: TimelineAccountEntity?,
|
||||||
|
// relevant when it is a placeholder
|
||||||
|
val loading: Boolean = false
|
||||||
|
)
|
||||||
|
|
||||||
|
@Entity(
|
||||||
|
primaryKeys = ["id", "tuskyAccountId"],
|
||||||
|
foreignKeys = (
|
||||||
|
[
|
||||||
|
ForeignKey(
|
||||||
|
entity = TimelineAccountEntity::class,
|
||||||
|
parentColumns = ["serverId", "tuskyAccountId"],
|
||||||
|
childColumns = ["accountId", "tuskyAccountId"]
|
||||||
|
),
|
||||||
|
ForeignKey(
|
||||||
|
entity = TimelineStatusEntity::class,
|
||||||
|
parentColumns = ["serverId", "tuskyAccountId"],
|
||||||
|
childColumns = ["statusId", "tuskyAccountId"]
|
||||||
|
),
|
||||||
|
ForeignKey(
|
||||||
|
entity = NotificationReportEntity::class,
|
||||||
|
parentColumns = ["serverId", "tuskyAccountId"],
|
||||||
|
childColumns = ["reportId", "tuskyAccountId"]
|
||||||
|
)
|
||||||
|
]
|
||||||
|
),
|
||||||
|
indices = [
|
||||||
|
Index("accountId", "tuskyAccountId"),
|
||||||
|
Index("statusId", "tuskyAccountId"),
|
||||||
|
Index("reportId", "tuskyAccountId"),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
@TypeConverters(Converters::class)
|
||||||
|
data class NotificationEntity(
|
||||||
|
// id of the account logged into Tusky this notifications belongs to
|
||||||
|
val tuskyAccountId: Long,
|
||||||
|
// null when placeholder
|
||||||
|
val type: Notification.Type?,
|
||||||
|
val id: String,
|
||||||
|
val accountId: String?,
|
||||||
|
val statusId: String?,
|
||||||
|
val reportId: String?,
|
||||||
|
// relevant when it is a placeholder
|
||||||
|
val loading: Boolean = false
|
||||||
|
)
|
||||||
|
|
||||||
|
@Entity(
|
||||||
|
primaryKeys = ["serverId", "tuskyAccountId"],
|
||||||
|
foreignKeys = (
|
||||||
|
[
|
||||||
|
ForeignKey(
|
||||||
|
entity = TimelineAccountEntity::class,
|
||||||
|
parentColumns = ["serverId", "tuskyAccountId"],
|
||||||
|
childColumns = ["targetAccountId", "tuskyAccountId"]
|
||||||
|
)
|
||||||
|
]
|
||||||
|
),
|
||||||
|
indices = [
|
||||||
|
Index("targetAccountId", "tuskyAccountId"),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
@TypeConverters(Converters::class)
|
||||||
|
data class NotificationReportEntity(
|
||||||
|
// id of the account logged into Tusky this report belongs to
|
||||||
|
val tuskyAccountId: Long,
|
||||||
|
val serverId: String,
|
||||||
|
val category: String,
|
||||||
|
val statusIds: List<String>?,
|
||||||
|
val createdAt: Date,
|
||||||
|
val targetAccountId: String?
|
||||||
|
)
|
|
@ -0,0 +1,37 @@
|
||||||
|
/* Copyright 2024 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.db.entity
|
||||||
|
|
||||||
|
import androidx.room.Entity
|
||||||
|
import androidx.room.TypeConverters
|
||||||
|
import com.keylesspalace.tusky.db.Converters
|
||||||
|
import com.keylesspalace.tusky.entity.Emoji
|
||||||
|
|
||||||
|
@Entity(
|
||||||
|
primaryKeys = ["serverId", "tuskyAccountId"]
|
||||||
|
)
|
||||||
|
@TypeConverters(Converters::class)
|
||||||
|
data class TimelineAccountEntity(
|
||||||
|
val serverId: String,
|
||||||
|
val tuskyAccountId: Long,
|
||||||
|
val localUsername: String,
|
||||||
|
val username: String,
|
||||||
|
val displayName: String,
|
||||||
|
val url: String,
|
||||||
|
val avatar: String,
|
||||||
|
val emojis: List<Emoji>,
|
||||||
|
val bot: Boolean
|
||||||
|
)
|
|
@ -13,40 +13,38 @@
|
||||||
* You should have received a copy of the GNU General Public License along with Tusky; if not,
|
* You should have received a copy of the GNU General Public License along with Tusky; if not,
|
||||||
* see <http://www.gnu.org/licenses>. */
|
* see <http://www.gnu.org/licenses>. */
|
||||||
|
|
||||||
package com.keylesspalace.tusky.db
|
package com.keylesspalace.tusky.db.entity
|
||||||
|
|
||||||
import androidx.room.Embedded
|
|
||||||
import androidx.room.Entity
|
import androidx.room.Entity
|
||||||
import androidx.room.ForeignKey
|
import androidx.room.ForeignKey
|
||||||
import androidx.room.Index
|
import androidx.room.Index
|
||||||
import androidx.room.TypeConverters
|
import androidx.room.TypeConverters
|
||||||
|
import com.keylesspalace.tusky.db.Converters
|
||||||
|
import com.keylesspalace.tusky.entity.Attachment
|
||||||
|
import com.keylesspalace.tusky.entity.Card
|
||||||
|
import com.keylesspalace.tusky.entity.Emoji
|
||||||
import com.keylesspalace.tusky.entity.FilterResult
|
import com.keylesspalace.tusky.entity.FilterResult
|
||||||
|
import com.keylesspalace.tusky.entity.HashTag
|
||||||
|
import com.keylesspalace.tusky.entity.Poll
|
||||||
import com.keylesspalace.tusky.entity.Status
|
import com.keylesspalace.tusky.entity.Status
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* We're trying to play smart here. Server sends us reblogs as two entities one embedded into
|
* Entity for caching status data. Used within home timelines and notifications.
|
||||||
* another (reblogged status is a field inside of "reblog" status). But it's really inefficient from
|
* The information if a status is a reblog is not stored here but in [HomeTimelineEntity].
|
||||||
* the DB perspective and doesn't matter much for the display/interaction purposes.
|
|
||||||
* What if when we store reblog we don't store almost empty "reblog status" but we store
|
|
||||||
* *reblogged* status and we embed "reblog status" into reblogged status. This reversed
|
|
||||||
* relationship takes much less space and is much faster to fetch (no N+1 type queries or JSON
|
|
||||||
* serialization).
|
|
||||||
* "Reblog status", if present, is marked by [reblogServerId], and [reblogAccountId]
|
|
||||||
* fields.
|
|
||||||
*/
|
*/
|
||||||
@Entity(
|
@Entity(
|
||||||
primaryKeys = ["serverId", "timelineUserId"],
|
primaryKeys = ["serverId", "tuskyAccountId"],
|
||||||
foreignKeys = (
|
foreignKeys = (
|
||||||
[
|
[
|
||||||
ForeignKey(
|
ForeignKey(
|
||||||
entity = TimelineAccountEntity::class,
|
entity = TimelineAccountEntity::class,
|
||||||
parentColumns = ["serverId", "timelineUserId"],
|
parentColumns = ["serverId", "tuskyAccountId"],
|
||||||
childColumns = ["authorServerId", "timelineUserId"]
|
childColumns = ["authorServerId", "tuskyAccountId"]
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
),
|
),
|
||||||
// Avoiding rescanning status table when accounts table changes. Recommended by Room(c).
|
// Avoiding rescanning status table when accounts table changes. Recommended by Room(c).
|
||||||
indices = [Index("authorServerId", "timelineUserId")]
|
indices = [Index("authorServerId", "tuskyAccountId")]
|
||||||
)
|
)
|
||||||
@TypeConverters(Converters::class)
|
@TypeConverters(Converters::class)
|
||||||
data class TimelineStatusEntity(
|
data class TimelineStatusEntity(
|
||||||
|
@ -54,14 +52,14 @@ data class TimelineStatusEntity(
|
||||||
val serverId: String,
|
val serverId: String,
|
||||||
val url: String?,
|
val url: String?,
|
||||||
// our local id for the logged in user in case there are multiple accounts per instance
|
// our local id for the logged in user in case there are multiple accounts per instance
|
||||||
val timelineUserId: Long,
|
val tuskyAccountId: Long,
|
||||||
val authorServerId: String?,
|
val authorServerId: String,
|
||||||
val inReplyToId: String?,
|
val inReplyToId: String?,
|
||||||
val inReplyToAccountId: String?,
|
val inReplyToAccountId: String?,
|
||||||
val content: String?,
|
val content: String,
|
||||||
val createdAt: Long,
|
val createdAt: Long,
|
||||||
val editedAt: Long?,
|
val editedAt: Long?,
|
||||||
val emojis: String?,
|
val emojis: List<Emoji>,
|
||||||
val reblogsCount: Int,
|
val reblogsCount: Int,
|
||||||
val favouritesCount: Int,
|
val favouritesCount: Int,
|
||||||
val repliesCount: Int,
|
val repliesCount: Int,
|
||||||
|
@ -71,50 +69,19 @@ data class TimelineStatusEntity(
|
||||||
val sensitive: Boolean,
|
val sensitive: Boolean,
|
||||||
val spoilerText: String,
|
val spoilerText: String,
|
||||||
val visibility: Status.Visibility,
|
val visibility: Status.Visibility,
|
||||||
val attachments: String?,
|
val attachments: List<Attachment>,
|
||||||
val mentions: String?,
|
val mentions: List<Status.Mention>,
|
||||||
val tags: String?,
|
val tags: List<HashTag>,
|
||||||
val application: String?,
|
val application: Status.Application?,
|
||||||
// if it has a reblogged status, it's id is stored here
|
// if it has a reblogged status, it's id is stored here
|
||||||
val reblogServerId: String?,
|
val poll: Poll?,
|
||||||
val reblogAccountId: String?,
|
val muted: Boolean,
|
||||||
val poll: String?,
|
|
||||||
val muted: Boolean?,
|
|
||||||
/** Also used as the "loading" attribute when this TimelineStatusEntity is a placeholder */
|
/** Also used as the "loading" attribute when this TimelineStatusEntity is a placeholder */
|
||||||
val expanded: Boolean,
|
val expanded: Boolean,
|
||||||
val contentCollapsed: Boolean,
|
val contentCollapsed: Boolean,
|
||||||
val contentShowing: Boolean,
|
val contentShowing: Boolean,
|
||||||
val pinned: Boolean,
|
val pinned: Boolean,
|
||||||
val card: String?,
|
val card: Card?,
|
||||||
val language: String?,
|
val language: String?,
|
||||||
val filtered: List<FilterResult>?
|
val filtered: List<FilterResult>
|
||||||
) {
|
|
||||||
val isPlaceholder: Boolean
|
|
||||||
get() = this.authorServerId == null
|
|
||||||
}
|
|
||||||
|
|
||||||
@Entity(
|
|
||||||
primaryKeys = ["serverId", "timelineUserId"]
|
|
||||||
)
|
|
||||||
data class TimelineAccountEntity(
|
|
||||||
val serverId: String,
|
|
||||||
val timelineUserId: Long,
|
|
||||||
val localUsername: String,
|
|
||||||
val username: String,
|
|
||||||
val displayName: String,
|
|
||||||
val url: String,
|
|
||||||
val avatar: String,
|
|
||||||
val emojis: String,
|
|
||||||
val bot: Boolean
|
|
||||||
)
|
|
||||||
|
|
||||||
data class TimelineStatusWithAccount(
|
|
||||||
@Embedded
|
|
||||||
val status: TimelineStatusEntity,
|
|
||||||
// null when placeholder
|
|
||||||
@Embedded(prefix = "a_")
|
|
||||||
val account: TimelineAccountEntity? = null,
|
|
||||||
// null when no reblog
|
|
||||||
@Embedded(prefix = "rb_")
|
|
||||||
val reblogAccount: TimelineAccountEntity? = null
|
|
||||||
)
|
)
|
|
@ -68,7 +68,8 @@ class AppModule {
|
||||||
AppDatabase.MIGRATION_38_39, AppDatabase.MIGRATION_39_40, AppDatabase.MIGRATION_40_41,
|
AppDatabase.MIGRATION_38_39, AppDatabase.MIGRATION_39_40, AppDatabase.MIGRATION_40_41,
|
||||||
AppDatabase.MIGRATION_41_42, AppDatabase.MIGRATION_42_43, AppDatabase.MIGRATION_43_44,
|
AppDatabase.MIGRATION_41_42, AppDatabase.MIGRATION_42_43, AppDatabase.MIGRATION_43_44,
|
||||||
AppDatabase.MIGRATION_44_45, AppDatabase.MIGRATION_45_46, AppDatabase.MIGRATION_46_47,
|
AppDatabase.MIGRATION_44_45, AppDatabase.MIGRATION_45_46, AppDatabase.MIGRATION_46_47,
|
||||||
AppDatabase.MIGRATION_47_48, AppDatabase.MIGRATION_52_53, AppDatabase.MIGRATION_54_56
|
AppDatabase.MIGRATION_47_48, AppDatabase.MIGRATION_52_53, AppDatabase.MIGRATION_54_56,
|
||||||
|
AppDatabase.MIGRATION_58_60
|
||||||
)
|
)
|
||||||
.build()
|
.build()
|
||||||
}
|
}
|
||||||
|
|
|
@ -21,6 +21,7 @@ import com.keylesspalace.tusky.components.account.media.AccountMediaFragment
|
||||||
import com.keylesspalace.tusky.components.accountlist.AccountListFragment
|
import com.keylesspalace.tusky.components.accountlist.AccountListFragment
|
||||||
import com.keylesspalace.tusky.components.conversation.ConversationsFragment
|
import com.keylesspalace.tusky.components.conversation.ConversationsFragment
|
||||||
import com.keylesspalace.tusky.components.domainblocks.DomainBlocksFragment
|
import com.keylesspalace.tusky.components.domainblocks.DomainBlocksFragment
|
||||||
|
import com.keylesspalace.tusky.components.notifications.NotificationsFragment
|
||||||
import com.keylesspalace.tusky.components.preference.AccountPreferencesFragment
|
import com.keylesspalace.tusky.components.preference.AccountPreferencesFragment
|
||||||
import com.keylesspalace.tusky.components.preference.NotificationPreferencesFragment
|
import com.keylesspalace.tusky.components.preference.NotificationPreferencesFragment
|
||||||
import com.keylesspalace.tusky.components.preference.PreferencesFragment
|
import com.keylesspalace.tusky.components.preference.PreferencesFragment
|
||||||
|
@ -35,7 +36,6 @@ import com.keylesspalace.tusky.components.timeline.TimelineFragment
|
||||||
import com.keylesspalace.tusky.components.trending.TrendingTagsFragment
|
import com.keylesspalace.tusky.components.trending.TrendingTagsFragment
|
||||||
import com.keylesspalace.tusky.components.viewthread.ViewThreadFragment
|
import com.keylesspalace.tusky.components.viewthread.ViewThreadFragment
|
||||||
import com.keylesspalace.tusky.components.viewthread.edits.ViewEditsFragment
|
import com.keylesspalace.tusky.components.viewthread.edits.ViewEditsFragment
|
||||||
import com.keylesspalace.tusky.fragment.NotificationsFragment
|
|
||||||
import com.keylesspalace.tusky.fragment.ViewVideoFragment
|
import com.keylesspalace.tusky.fragment.ViewVideoFragment
|
||||||
import dagger.Module
|
import dagger.Module
|
||||||
import dagger.android.ContributesAndroidInjector
|
import dagger.android.ContributesAndroidInjector
|
||||||
|
|
|
@ -33,6 +33,7 @@ import com.keylesspalace.tusky.components.filters.EditFilterViewModel
|
||||||
import com.keylesspalace.tusky.components.filters.FiltersViewModel
|
import com.keylesspalace.tusky.components.filters.FiltersViewModel
|
||||||
import com.keylesspalace.tusky.components.followedtags.FollowedTagsViewModel
|
import com.keylesspalace.tusky.components.followedtags.FollowedTagsViewModel
|
||||||
import com.keylesspalace.tusky.components.login.LoginWebViewViewModel
|
import com.keylesspalace.tusky.components.login.LoginWebViewViewModel
|
||||||
|
import com.keylesspalace.tusky.components.notifications.NotificationsViewModel
|
||||||
import com.keylesspalace.tusky.components.report.ReportViewModel
|
import com.keylesspalace.tusky.components.report.ReportViewModel
|
||||||
import com.keylesspalace.tusky.components.scheduled.ScheduledStatusViewModel
|
import com.keylesspalace.tusky.components.scheduled.ScheduledStatusViewModel
|
||||||
import com.keylesspalace.tusky.components.search.SearchViewModel
|
import com.keylesspalace.tusky.components.search.SearchViewModel
|
||||||
|
@ -192,5 +193,10 @@ abstract class ViewModelModule {
|
||||||
@ViewModelKey(DomainBlocksViewModel::class)
|
@ViewModelKey(DomainBlocksViewModel::class)
|
||||||
internal abstract fun instanceMuteViewModel(viewModel: DomainBlocksViewModel): ViewModel
|
internal abstract fun instanceMuteViewModel(viewModel: DomainBlocksViewModel): ViewModel
|
||||||
|
|
||||||
|
@Binds
|
||||||
|
@IntoMap
|
||||||
|
@ViewModelKey(NotificationsViewModel::class)
|
||||||
|
internal abstract fun notificationsViewModel(viewModel: NotificationsViewModel): ViewModel
|
||||||
|
|
||||||
// Add more ViewModels here
|
// Add more ViewModels here
|
||||||
}
|
}
|
||||||
|
|
|
@ -75,7 +75,6 @@ data class Notification(
|
||||||
REPORT("admin.report", R.string.notification_report_name);
|
REPORT("admin.report", R.string.notification_report_name);
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
@JvmStatic
|
|
||||||
fun byString(s: String): Type {
|
fun byString(s: String): Type {
|
||||||
return entries.firstOrNull { it.presentation == s } ?: UNKNOWN
|
return entries.firstOrNull { it.presentation == s } ?: UNKNOWN
|
||||||
}
|
}
|
||||||
|
@ -85,25 +84,9 @@ data class Notification(
|
||||||
listOf(MENTION, REBLOG, FAVOURITE, FOLLOW, FOLLOW_REQUEST, POLL, STATUS, SIGN_UP, UPDATE, REPORT)
|
listOf(MENTION, REBLOG, FAVOURITE, FOLLOW, FOLLOW_REQUEST, POLL, STATUS, SIGN_UP, UPDATE, REPORT)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun toString(): String {
|
override fun toString() = presentation
|
||||||
return presentation
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun hashCode(): Int {
|
|
||||||
return id.hashCode()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun equals(other: Any?): Boolean {
|
|
||||||
if (other !is Notification) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return other.id == this.id
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Helper for Java */
|
|
||||||
fun copyWithStatus(status: Status?): Notification = copy(status = status)
|
|
||||||
|
|
||||||
// for Pleroma compatibility that uses Mention type
|
// for Pleroma compatibility that uses Mention type
|
||||||
fun rewriteToStatusTypeIfNeeded(accountId: String): Notification {
|
fun rewriteToStatusTypeIfNeeded(accountId: String): Notification {
|
||||||
if (type == Type.MENTION && status != null) {
|
if (type == Type.MENTION && status != null) {
|
||||||
|
|
|
@ -47,7 +47,7 @@ data class Status(
|
||||||
@Json(name = "media_attachments") val attachments: List<Attachment>,
|
@Json(name = "media_attachments") val attachments: List<Attachment>,
|
||||||
val mentions: List<Mention>,
|
val mentions: List<Mention>,
|
||||||
// Use null to mark the absence of tags because of semantic differences in LinkHelper
|
// Use null to mark the absence of tags because of semantic differences in LinkHelper
|
||||||
val tags: List<HashTag>? = null,
|
val tags: List<HashTag> = emptyList(),
|
||||||
val application: Application? = null,
|
val application: Application? = null,
|
||||||
val pinned: Boolean = false,
|
val pinned: Boolean = false,
|
||||||
val muted: Boolean = false,
|
val muted: Boolean = false,
|
||||||
|
@ -66,13 +66,6 @@ data class Status(
|
||||||
val actionableStatus: Status
|
val actionableStatus: Status
|
||||||
get() = reblog ?: this
|
get() = reblog ?: this
|
||||||
|
|
||||||
/** Helpers for Java */
|
|
||||||
fun copyWithFavourited(favourited: Boolean): Status = copy(favourited = favourited)
|
|
||||||
fun copyWithReblogged(reblogged: Boolean): Status = copy(reblogged = reblogged)
|
|
||||||
fun copyWithBookmarked(bookmarked: Boolean): Status = copy(bookmarked = bookmarked)
|
|
||||||
fun copyWithPoll(poll: Poll?): Status = copy(poll = poll)
|
|
||||||
fun copyWithPinned(pinned: Boolean): Status = copy(pinned = pinned)
|
|
||||||
|
|
||||||
@JsonClass(generateAdapter = false)
|
@JsonClass(generateAdapter = false)
|
||||||
enum class Visibility(val num: Int) {
|
enum class Visibility(val num: Int) {
|
||||||
UNKNOWN(0),
|
UNKNOWN(0),
|
||||||
|
|
File diff suppressed because it is too large
Load Diff
|
@ -48,8 +48,8 @@ import com.keylesspalace.tusky.components.compose.ComposeActivity.Companion.star
|
||||||
import com.keylesspalace.tusky.components.compose.ComposeActivity.ComposeOptions
|
import com.keylesspalace.tusky.components.compose.ComposeActivity.ComposeOptions
|
||||||
import com.keylesspalace.tusky.components.instanceinfo.InstanceInfoRepository
|
import com.keylesspalace.tusky.components.instanceinfo.InstanceInfoRepository
|
||||||
import com.keylesspalace.tusky.components.report.ReportActivity.Companion.getIntent
|
import com.keylesspalace.tusky.components.report.ReportActivity.Companion.getIntent
|
||||||
import com.keylesspalace.tusky.db.AccountEntity
|
|
||||||
import com.keylesspalace.tusky.db.AccountManager
|
import com.keylesspalace.tusky.db.AccountManager
|
||||||
|
import com.keylesspalace.tusky.db.entity.AccountEntity
|
||||||
import com.keylesspalace.tusky.di.Injectable
|
import com.keylesspalace.tusky.di.Injectable
|
||||||
import com.keylesspalace.tusky.entity.Attachment
|
import com.keylesspalace.tusky.entity.Attachment
|
||||||
import com.keylesspalace.tusky.entity.Status
|
import com.keylesspalace.tusky.entity.Status
|
||||||
|
|
|
@ -15,7 +15,7 @@
|
||||||
|
|
||||||
package com.keylesspalace.tusky.interfaces
|
package com.keylesspalace.tusky.interfaces
|
||||||
|
|
||||||
import com.keylesspalace.tusky.db.AccountEntity
|
import com.keylesspalace.tusky.db.entity.AccountEntity
|
||||||
|
|
||||||
interface AccountSelectionListener {
|
interface AccountSelectionListener {
|
||||||
fun onAccountSelected(account: AccountEntity)
|
fun onAccountSelected(account: AccountEntity)
|
||||||
|
|
|
@ -137,15 +137,18 @@ interface MastodonApi {
|
||||||
): Response<List<Status>>
|
): Response<List<Status>>
|
||||||
|
|
||||||
@GET("api/v1/notifications")
|
@GET("api/v1/notifications")
|
||||||
|
@Throws(Exception::class)
|
||||||
suspend fun notifications(
|
suspend fun notifications(
|
||||||
/** Return results older than this ID */
|
/** Return results older than this ID */
|
||||||
@Query("max_id") maxId: String?,
|
@Query("max_id") maxId: String? = null,
|
||||||
/** Return results newer than this ID */
|
/** Return results newer than this ID */
|
||||||
@Query("since_id") sinceId: String?,
|
@Query("since_id") sinceId: String? = null,
|
||||||
|
/** Return results immediately newer than this ID */
|
||||||
|
@Query("min_id") minId: String? = null,
|
||||||
/** Maximum number of results to return. Defaults to 15, max is 30 */
|
/** Maximum number of results to return. Defaults to 15, max is 30 */
|
||||||
@Query("limit") limit: Int?,
|
@Query("limit") limit: Int? = null,
|
||||||
/** Types to excludes from the results */
|
/** Types to excludes from the results */
|
||||||
@Query("exclude_types[]") excludes: Set<Notification.Type>?
|
@Query("exclude_types[]") excludes: Set<Notification.Type>? = null
|
||||||
): Response<List<Notification>>
|
): Response<List<Notification>>
|
||||||
|
|
||||||
/** Fetch a single notification */
|
/** Fetch a single notification */
|
||||||
|
|
|
@ -20,9 +20,9 @@ import android.content.BroadcastReceiver
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import com.keylesspalace.tusky.components.notifications.canEnablePushNotifications
|
import com.keylesspalace.tusky.components.systemnotifications.canEnablePushNotifications
|
||||||
import com.keylesspalace.tusky.components.notifications.isUnifiedPushNotificationEnabledForAccount
|
import com.keylesspalace.tusky.components.systemnotifications.isUnifiedPushNotificationEnabledForAccount
|
||||||
import com.keylesspalace.tusky.components.notifications.updateUnifiedPushSubscription
|
import com.keylesspalace.tusky.components.systemnotifications.updateUnifiedPushSubscription
|
||||||
import com.keylesspalace.tusky.db.AccountManager
|
import com.keylesspalace.tusky.db.AccountManager
|
||||||
import com.keylesspalace.tusky.di.ApplicationScope
|
import com.keylesspalace.tusky.di.ApplicationScope
|
||||||
import com.keylesspalace.tusky.network.MastodonApi
|
import com.keylesspalace.tusky.network.MastodonApi
|
||||||
|
|
|
@ -24,7 +24,7 @@ import androidx.core.app.NotificationCompat
|
||||||
import androidx.core.app.NotificationManagerCompat
|
import androidx.core.app.NotificationManagerCompat
|
||||||
import androidx.core.app.RemoteInput
|
import androidx.core.app.RemoteInput
|
||||||
import com.keylesspalace.tusky.R
|
import com.keylesspalace.tusky.R
|
||||||
import com.keylesspalace.tusky.components.notifications.NotificationHelper
|
import com.keylesspalace.tusky.components.systemnotifications.NotificationHelper
|
||||||
import com.keylesspalace.tusky.db.AccountManager
|
import com.keylesspalace.tusky.db.AccountManager
|
||||||
import com.keylesspalace.tusky.entity.Status
|
import com.keylesspalace.tusky.entity.Status
|
||||||
import com.keylesspalace.tusky.service.SendStatusService
|
import com.keylesspalace.tusky.service.SendStatusService
|
||||||
|
|
|
@ -20,8 +20,8 @@ import android.content.Intent
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import androidx.work.OneTimeWorkRequest
|
import androidx.work.OneTimeWorkRequest
|
||||||
import androidx.work.WorkManager
|
import androidx.work.WorkManager
|
||||||
import com.keylesspalace.tusky.components.notifications.registerUnifiedPushEndpoint
|
import com.keylesspalace.tusky.components.systemnotifications.registerUnifiedPushEndpoint
|
||||||
import com.keylesspalace.tusky.components.notifications.unregisterUnifiedPushEndpoint
|
import com.keylesspalace.tusky.components.systemnotifications.unregisterUnifiedPushEndpoint
|
||||||
import com.keylesspalace.tusky.db.AccountManager
|
import com.keylesspalace.tusky.db.AccountManager
|
||||||
import com.keylesspalace.tusky.di.ApplicationScope
|
import com.keylesspalace.tusky.di.ApplicationScope
|
||||||
import com.keylesspalace.tusky.network.MastodonApi
|
import com.keylesspalace.tusky.network.MastodonApi
|
||||||
|
|
|
@ -42,7 +42,7 @@ import com.keylesspalace.tusky.appstore.StatusScheduledEvent
|
||||||
import com.keylesspalace.tusky.components.compose.MediaUploader
|
import com.keylesspalace.tusky.components.compose.MediaUploader
|
||||||
import com.keylesspalace.tusky.components.compose.UploadEvent
|
import com.keylesspalace.tusky.components.compose.UploadEvent
|
||||||
import com.keylesspalace.tusky.components.drafts.DraftHelper
|
import com.keylesspalace.tusky.components.drafts.DraftHelper
|
||||||
import com.keylesspalace.tusky.components.notifications.NotificationHelper
|
import com.keylesspalace.tusky.components.systemnotifications.NotificationHelper
|
||||||
import com.keylesspalace.tusky.db.AccountManager
|
import com.keylesspalace.tusky.db.AccountManager
|
||||||
import com.keylesspalace.tusky.di.Injectable
|
import com.keylesspalace.tusky.di.Injectable
|
||||||
import com.keylesspalace.tusky.entity.Attachment
|
import com.keylesspalace.tusky.entity.Attachment
|
||||||
|
|
|
@ -3,8 +3,8 @@ package com.keylesspalace.tusky.settings
|
||||||
import androidx.preference.PreferenceDataStore
|
import androidx.preference.PreferenceDataStore
|
||||||
import com.keylesspalace.tusky.appstore.EventHub
|
import com.keylesspalace.tusky.appstore.EventHub
|
||||||
import com.keylesspalace.tusky.appstore.PreferenceChangedEvent
|
import com.keylesspalace.tusky.appstore.PreferenceChangedEvent
|
||||||
import com.keylesspalace.tusky.db.AccountEntity
|
|
||||||
import com.keylesspalace.tusky.db.AccountManager
|
import com.keylesspalace.tusky.db.AccountManager
|
||||||
|
import com.keylesspalace.tusky.db.entity.AccountEntity
|
||||||
import com.keylesspalace.tusky.di.ApplicationScope
|
import com.keylesspalace.tusky.di.ApplicationScope
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
|
|
@ -62,13 +62,13 @@ object PrefKeys {
|
||||||
const val READING_ORDER = "readingOrder"
|
const val READING_ORDER = "readingOrder"
|
||||||
const val MAIN_NAV_POSITION = "mainNavPosition"
|
const val MAIN_NAV_POSITION = "mainNavPosition"
|
||||||
const val HIDE_TOP_TOOLBAR = "hideTopToolbar"
|
const val HIDE_TOP_TOOLBAR = "hideTopToolbar"
|
||||||
|
const val SHOW_NOTIFICATIONS_FILTER = "showNotificationsFilter"
|
||||||
const val ABSOLUTE_TIME_VIEW = "absoluteTimeView"
|
const val ABSOLUTE_TIME_VIEW = "absoluteTimeView"
|
||||||
const val SHOW_BOT_OVERLAY = "showBotOverlay"
|
const val SHOW_BOT_OVERLAY = "showBotOverlay"
|
||||||
const val ANIMATE_GIF_AVATARS = "animateGifAvatars"
|
const val ANIMATE_GIF_AVATARS = "animateGifAvatars"
|
||||||
const val USE_BLURHASH = "useBlurhash"
|
const val USE_BLURHASH = "useBlurhash"
|
||||||
const val SHOW_SELF_USERNAME = "showSelfUsername"
|
const val SHOW_SELF_USERNAME = "showSelfUsername"
|
||||||
const val SHOW_CARDS_IN_TIMELINES = "showCardsInTimelines"
|
const val SHOW_CARDS_IN_TIMELINES = "showCardsInTimelines"
|
||||||
const val SHOW_NOTIFICATIONS_FILTER = "showNotificationsFilter"
|
|
||||||
const val CONFIRM_REBLOGS = "confirmReblogs"
|
const val CONFIRM_REBLOGS = "confirmReblogs"
|
||||||
const val CONFIRM_FAVOURITES = "confirmFavourites"
|
const val CONFIRM_FAVOURITES = "confirmFavourites"
|
||||||
const val ENABLE_SWIPE_FOR_TABS = "enableSwipeForTabs"
|
const val ENABLE_SWIPE_FOR_TABS = "enableSwipeForTabs"
|
||||||
|
@ -111,9 +111,4 @@ object PrefKeys {
|
||||||
|
|
||||||
/** UI text scaling factor, stored as float, 100 = 100% = no scaling */
|
/** UI text scaling factor, stored as float, 100 = 100% = no scaling */
|
||||||
const val UI_TEXT_SCALE_RATIO = "uiTextScaleRatio"
|
const val UI_TEXT_SCALE_RATIO = "uiTextScaleRatio"
|
||||||
|
|
||||||
/** Keys that are no longer used (e.g., the preference has been removed */
|
|
||||||
object Deprecated {
|
|
||||||
const val SHOW_NOTIFICATIONS_FILTER = "showNotificationsFilter"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -3,7 +3,7 @@ package com.keylesspalace.tusky.usecase
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import androidx.room.withTransaction
|
import androidx.room.withTransaction
|
||||||
import com.keylesspalace.tusky.db.AppDatabase
|
import com.keylesspalace.tusky.db.AppDatabase
|
||||||
import com.keylesspalace.tusky.db.TimelineDao
|
import com.keylesspalace.tusky.db.dao.TimelineDao
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -25,18 +25,18 @@ class DeveloperToolsUseCase @Inject constructor(
|
||||||
*/
|
*/
|
||||||
suspend fun createLoadMoreGap(accountId: Long) {
|
suspend fun createLoadMoreGap(accountId: Long) {
|
||||||
db.withTransaction {
|
db.withTransaction {
|
||||||
val ids = timelineDao.getMostRecentNStatusIds(accountId, 10)
|
val ids = timelineDao.getMostRecentNHomeTimelineIds(accountId, 10)
|
||||||
val maxId = ids[2]
|
val maxId = ids[2]
|
||||||
val minId = ids[8]
|
val minId = ids[8]
|
||||||
val placeHolderId = ids[9]
|
val placeHolderId = ids[9]
|
||||||
|
|
||||||
Log.d(
|
Log.d(
|
||||||
"TAG",
|
TAG,
|
||||||
"createLoadMoreGap: creating gap between $minId .. $maxId (new placeholder: $placeHolderId"
|
"createLoadMoreGap: creating gap between $minId .. $maxId (new placeholder: $placeHolderId"
|
||||||
)
|
)
|
||||||
|
|
||||||
timelineDao.deleteRange(accountId, minId, maxId)
|
timelineDao.deleteRange(accountId, minId, maxId)
|
||||||
timelineDao.convertStatustoPlaceholder(placeHolderId)
|
timelineDao.convertHomeTimelineItemToPlaceholder(placeHolderId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -2,10 +2,10 @@ package com.keylesspalace.tusky.usecase
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import com.keylesspalace.tusky.components.drafts.DraftHelper
|
import com.keylesspalace.tusky.components.drafts.DraftHelper
|
||||||
import com.keylesspalace.tusky.components.notifications.NotificationHelper
|
import com.keylesspalace.tusky.components.systemnotifications.NotificationHelper
|
||||||
import com.keylesspalace.tusky.components.notifications.disableUnifiedPushNotificationsForAccount
|
import com.keylesspalace.tusky.components.systemnotifications.disableUnifiedPushNotificationsForAccount
|
||||||
import com.keylesspalace.tusky.db.AccountManager
|
import com.keylesspalace.tusky.db.AccountManager
|
||||||
import com.keylesspalace.tusky.db.AppDatabase
|
import com.keylesspalace.tusky.db.DatabaseCleaner
|
||||||
import com.keylesspalace.tusky.network.MastodonApi
|
import com.keylesspalace.tusky.network.MastodonApi
|
||||||
import com.keylesspalace.tusky.util.ShareShortcutHelper
|
import com.keylesspalace.tusky.util.ShareShortcutHelper
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
@ -13,7 +13,7 @@ import javax.inject.Inject
|
||||||
class LogoutUsecase @Inject constructor(
|
class LogoutUsecase @Inject constructor(
|
||||||
private val context: Context,
|
private val context: Context,
|
||||||
private val api: MastodonApi,
|
private val api: MastodonApi,
|
||||||
private val db: AppDatabase,
|
private val databaseCleaner: DatabaseCleaner,
|
||||||
private val accountManager: AccountManager,
|
private val accountManager: AccountManager,
|
||||||
private val draftHelper: DraftHelper,
|
private val draftHelper: DraftHelper,
|
||||||
private val shareShortcutHelper: ShareShortcutHelper
|
private val shareShortcutHelper: ShareShortcutHelper
|
||||||
|
@ -53,8 +53,7 @@ class LogoutUsecase @Inject constructor(
|
||||||
val otherAccountAvailable = accountManager.logActiveAccountOut() != null
|
val otherAccountAvailable = accountManager.logActiveAccountOut() != null
|
||||||
|
|
||||||
// clear the database - this could trigger network calls so do it last when all tokens are gone
|
// clear the database - this could trigger network calls so do it last when all tokens are gone
|
||||||
db.timelineDao().removeAll(activeAccount.id)
|
databaseCleaner.cleanupEverything(activeAccount.id)
|
||||||
db.conversationDao().deleteForAccount(activeAccount.id)
|
|
||||||
draftHelper.deleteAllDraftsAndAttachmentsForAccount(activeAccount.id)
|
draftHelper.deleteAllDraftsAndAttachmentsForAccount(activeAccount.id)
|
||||||
|
|
||||||
// remove shortcut associated with the account
|
// remove shortcut associated with the account
|
||||||
|
|
|
@ -20,7 +20,6 @@ import at.connyduck.calladapter.networkresult.NetworkResult
|
||||||
import at.connyduck.calladapter.networkresult.fold
|
import at.connyduck.calladapter.networkresult.fold
|
||||||
import at.connyduck.calladapter.networkresult.onFailure
|
import at.connyduck.calladapter.networkresult.onFailure
|
||||||
import at.connyduck.calladapter.networkresult.onSuccess
|
import at.connyduck.calladapter.networkresult.onSuccess
|
||||||
import at.connyduck.calladapter.networkresult.runCatching
|
|
||||||
import com.keylesspalace.tusky.appstore.BlockEvent
|
import com.keylesspalace.tusky.appstore.BlockEvent
|
||||||
import com.keylesspalace.tusky.appstore.EventHub
|
import com.keylesspalace.tusky.appstore.EventHub
|
||||||
import com.keylesspalace.tusky.appstore.MuteConversationEvent
|
import com.keylesspalace.tusky.appstore.MuteConversationEvent
|
||||||
|
@ -29,17 +28,13 @@ import com.keylesspalace.tusky.appstore.PollVoteEvent
|
||||||
import com.keylesspalace.tusky.appstore.StatusChangedEvent
|
import com.keylesspalace.tusky.appstore.StatusChangedEvent
|
||||||
import com.keylesspalace.tusky.appstore.StatusDeletedEvent
|
import com.keylesspalace.tusky.appstore.StatusDeletedEvent
|
||||||
import com.keylesspalace.tusky.entity.DeletedStatus
|
import com.keylesspalace.tusky.entity.DeletedStatus
|
||||||
import com.keylesspalace.tusky.entity.Notification
|
|
||||||
import com.keylesspalace.tusky.entity.Poll
|
import com.keylesspalace.tusky.entity.Poll
|
||||||
import com.keylesspalace.tusky.entity.Relationship
|
|
||||||
import com.keylesspalace.tusky.entity.Status
|
import com.keylesspalace.tusky.entity.Status
|
||||||
import com.keylesspalace.tusky.entity.Translation
|
import com.keylesspalace.tusky.entity.Translation
|
||||||
import com.keylesspalace.tusky.network.MastodonApi
|
import com.keylesspalace.tusky.network.MastodonApi
|
||||||
import com.keylesspalace.tusky.util.Single
|
|
||||||
import com.keylesspalace.tusky.util.getServerErrorMessage
|
import com.keylesspalace.tusky.util.getServerErrorMessage
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import retrofit2.Response
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Created by charlag on 3/24/18.
|
* Created by charlag on 3/24/18.
|
||||||
|
@ -66,10 +61,6 @@ class TimelineCases @Inject constructor(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun reblogOld(statusId: String, reblog: Boolean): Single<Status> {
|
|
||||||
return Single { reblog(statusId, reblog) }
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun favourite(statusId: String, favourite: Boolean): NetworkResult<Status> {
|
suspend fun favourite(statusId: String, favourite: Boolean): NetworkResult<Status> {
|
||||||
return if (favourite) {
|
return if (favourite) {
|
||||||
mastodonApi.favouriteStatus(statusId)
|
mastodonApi.favouriteStatus(statusId)
|
||||||
|
@ -80,10 +71,6 @@ class TimelineCases @Inject constructor(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun favouriteOld(statusId: String, favourite: Boolean): Single<Status> {
|
|
||||||
return Single { favourite(statusId, favourite) }
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun bookmark(statusId: String, bookmark: Boolean): NetworkResult<Status> {
|
suspend fun bookmark(statusId: String, bookmark: Boolean): NetworkResult<Status> {
|
||||||
return if (bookmark) {
|
return if (bookmark) {
|
||||||
mastodonApi.bookmarkStatus(statusId)
|
mastodonApi.bookmarkStatus(statusId)
|
||||||
|
@ -94,10 +81,6 @@ class TimelineCases @Inject constructor(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun bookmarkOld(statusId: String, bookmark: Boolean): Single<Status> {
|
|
||||||
return Single { bookmark(statusId, bookmark) }
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun muteConversation(statusId: String, mute: Boolean): NetworkResult<Status> {
|
suspend fun muteConversation(statusId: String, mute: Boolean): NetworkResult<Status> {
|
||||||
return if (mute) {
|
return if (mute) {
|
||||||
mastodonApi.muteConversation(statusId)
|
mastodonApi.muteConversation(statusId)
|
||||||
|
@ -160,31 +143,6 @@ class TimelineCases @Inject constructor(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun voteInPollOld(statusId: String, pollId: String, choices: List<Int>): Single<Poll> {
|
|
||||||
return Single { voteInPoll(statusId, pollId, choices) }
|
|
||||||
}
|
|
||||||
|
|
||||||
fun acceptFollowRequestOld(accountId: String): Single<Relationship> {
|
|
||||||
return Single { mastodonApi.authorizeFollowRequest(accountId) }
|
|
||||||
}
|
|
||||||
|
|
||||||
fun rejectFollowRequestOld(accountId: String): Single<Relationship> {
|
|
||||||
return Single { mastodonApi.rejectFollowRequest(accountId) }
|
|
||||||
}
|
|
||||||
|
|
||||||
fun notificationsOld(
|
|
||||||
maxId: String?,
|
|
||||||
sinceId: String?,
|
|
||||||
limit: Int?,
|
|
||||||
excludes: Set<Notification.Type>?
|
|
||||||
): Single<Response<List<Notification>>> {
|
|
||||||
return Single { runCatching { mastodonApi.notifications(maxId, sinceId, limit, excludes) } }
|
|
||||||
}
|
|
||||||
|
|
||||||
fun clearNotificationsOld(): Single<Unit> {
|
|
||||||
return Single { mastodonApi.clearNotifications() }
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun translate(
|
suspend fun translate(
|
||||||
statusId: String
|
statusId: String
|
||||||
): NetworkResult<Translation> {
|
): NetworkResult<Translation> {
|
||||||
|
|
|
@ -1,65 +0,0 @@
|
||||||
/*
|
|
||||||
* Copyright 2023 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.util
|
|
||||||
|
|
||||||
import kotlin.time.Duration
|
|
||||||
import kotlin.time.TimeMark
|
|
||||||
import kotlin.time.TimeSource
|
|
||||||
import kotlinx.coroutines.flow.Flow
|
|
||||||
import kotlinx.coroutines.flow.flow
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns a flow that mirrors the original flow, but filters out values that occur within
|
|
||||||
* [timeout] of the previously emitted value. The first value is always emitted.
|
|
||||||
*
|
|
||||||
* Example:
|
|
||||||
*
|
|
||||||
* ```kotlin
|
|
||||||
* flow {
|
|
||||||
* emit(1)
|
|
||||||
* delay(90.milliseconds)
|
|
||||||
* emit(2)
|
|
||||||
* delay(90.milliseconds)
|
|
||||||
* emit(3)
|
|
||||||
* delay(1010.milliseconds)
|
|
||||||
* emit(4)
|
|
||||||
* delay(1010.milliseconds)
|
|
||||||
* emit(5)
|
|
||||||
* }.throttleFirst(1000.milliseconds)
|
|
||||||
* ```
|
|
||||||
*
|
|
||||||
* produces the following emissions.
|
|
||||||
*
|
|
||||||
* ```text
|
|
||||||
* 1, 4, 5
|
|
||||||
* ```
|
|
||||||
*
|
|
||||||
* @see kotlinx.coroutines.flow.debounce(Duration)
|
|
||||||
* @param timeout Emissions within this duration of the last emission are filtered
|
|
||||||
* @param timeSource Used to measure elapsed time. Normally only overridden in tests
|
|
||||||
*/
|
|
||||||
fun <T> Flow<T>.throttleFirst(timeout: Duration, timeSource: TimeSource = TimeSource.Monotonic) =
|
|
||||||
flow {
|
|
||||||
var marker: TimeMark? = null
|
|
||||||
collect {
|
|
||||||
if (marker == null || marker!!.elapsedNow() >= timeout) {
|
|
||||||
emit(it)
|
|
||||||
marker = timeSource.markNow()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -20,13 +20,6 @@ package com.keylesspalace.tusky.util
|
||||||
import java.util.ArrayList
|
import java.util.ArrayList
|
||||||
import java.util.LinkedHashSet
|
import java.util.LinkedHashSet
|
||||||
|
|
||||||
/**
|
|
||||||
* @return true if list is null or else return list.isEmpty()
|
|
||||||
*/
|
|
||||||
fun isEmpty(list: List<*>?): Boolean {
|
|
||||||
return list == null || list.isEmpty()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return a new ArrayList containing the elements without duplicates in the same order
|
* @return a new ArrayList containing the elements without duplicates in the same order
|
||||||
*/
|
*/
|
||||||
|
|
|
@ -18,7 +18,7 @@ package com.keylesspalace.tusky.util
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import androidx.appcompat.app.AppCompatDelegate
|
import androidx.appcompat.app.AppCompatDelegate
|
||||||
import androidx.core.os.LocaleListCompat
|
import androidx.core.os.LocaleListCompat
|
||||||
import com.keylesspalace.tusky.db.AccountEntity
|
import com.keylesspalace.tusky.db.entity.AccountEntity
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
|
|
||||||
private const val TAG: String = "LocaleUtils"
|
private const val TAG: String = "LocaleUtils"
|
||||||
|
|
|
@ -1,74 +0,0 @@
|
||||||
package com.keylesspalace.tusky.util
|
|
||||||
|
|
||||||
import androidx.arch.core.util.Function
|
|
||||||
|
|
||||||
/**
|
|
||||||
* This list implementation can help to keep two lists in sync - like real models and view models.
|
|
||||||
*
|
|
||||||
* Every operation on the main list triggers update of the supplementary list (but not vice versa).
|
|
||||||
*
|
|
||||||
* This makes sure that the main list is always the source of truth.
|
|
||||||
*
|
|
||||||
* Main list is projected to the supplementary list by the passed mapper function.
|
|
||||||
*
|
|
||||||
* Paired list is newer actually exposed and clients are provided with `getPairedCopy()`,
|
|
||||||
* `getPairedItem()` and `setPairedItem()`. This prevents modifications of the
|
|
||||||
* supplementary list size so lists are always have the same length.
|
|
||||||
*
|
|
||||||
* This implementation will not try to recover from exceptional cases so lists may be out of sync
|
|
||||||
* after the exception.
|
|
||||||
*
|
|
||||||
* It is most useful with immutable data because we cannot track changes inside stored objects.
|
|
||||||
*
|
|
||||||
* @param T type of elements in the main list
|
|
||||||
* @param V type of elements in supplementary list
|
|
||||||
* @param mapper Function, which will be used to translate items from the main list to the
|
|
||||||
* supplementary one.
|
|
||||||
* @constructor
|
|
||||||
*/
|
|
||||||
class PairedList<T, V>(private val mapper: Function<T, out V>) : AbstractMutableList<T>() {
|
|
||||||
private val main: MutableList<T> = ArrayList()
|
|
||||||
private val synced: MutableList<V> = ArrayList()
|
|
||||||
|
|
||||||
val pairedCopy: List<V>
|
|
||||||
get() = ArrayList(synced)
|
|
||||||
|
|
||||||
fun getPairedItem(index: Int): V {
|
|
||||||
return synced[index]
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getPairedItemOrNull(index: Int): V? {
|
|
||||||
return synced.getOrNull(index)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setPairedItem(index: Int, element: V) {
|
|
||||||
synced[index] = element
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun get(index: Int): T {
|
|
||||||
return main[index]
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun set(index: Int, element: T): T {
|
|
||||||
synced[index] = mapper.apply(element)
|
|
||||||
return main.set(index, element)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun add(element: T): Boolean {
|
|
||||||
synced.add(mapper.apply(element))
|
|
||||||
return main.add(element)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun add(index: Int, element: T) {
|
|
||||||
synced.add(index, mapper.apply(element))
|
|
||||||
main.add(index, element)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun removeAt(index: Int): T {
|
|
||||||
synced.removeAt(index)
|
|
||||||
return main.removeAt(index)
|
|
||||||
}
|
|
||||||
|
|
||||||
override val size: Int
|
|
||||||
get() = main.size
|
|
||||||
}
|
|
|
@ -28,8 +28,8 @@ import androidx.core.graphics.drawable.IconCompat
|
||||||
import com.bumptech.glide.Glide
|
import com.bumptech.glide.Glide
|
||||||
import com.keylesspalace.tusky.MainActivity
|
import com.keylesspalace.tusky.MainActivity
|
||||||
import com.keylesspalace.tusky.R
|
import com.keylesspalace.tusky.R
|
||||||
import com.keylesspalace.tusky.db.AccountEntity
|
|
||||||
import com.keylesspalace.tusky.db.AccountManager
|
import com.keylesspalace.tusky.db.AccountManager
|
||||||
|
import com.keylesspalace.tusky.db.entity.AccountEntity
|
||||||
import com.keylesspalace.tusky.di.ApplicationScope
|
import com.keylesspalace.tusky.di.ApplicationScope
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
|
|
@ -1,29 +0,0 @@
|
||||||
package com.keylesspalace.tusky.util
|
|
||||||
|
|
||||||
import androidx.lifecycle.LifecycleOwner
|
|
||||||
import androidx.lifecycle.lifecycleScope
|
|
||||||
import at.connyduck.calladapter.networkresult.NetworkResult
|
|
||||||
import at.connyduck.calladapter.networkresult.fold
|
|
||||||
import java.util.function.Consumer
|
|
||||||
import kotlinx.coroutines.CoroutineScope
|
|
||||||
import kotlinx.coroutines.Job
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Simple reimplementation of RxJava's Single using a Kotlin coroutine,
|
|
||||||
* intended to be consumed by legacy Java code only.
|
|
||||||
*/
|
|
||||||
class Single<T>(private val producer: suspend CoroutineScope.() -> NetworkResult<T>) {
|
|
||||||
fun subscribe(
|
|
||||||
owner: LifecycleOwner,
|
|
||||||
onSuccess: Consumer<T>,
|
|
||||||
onError: Consumer<Throwable>
|
|
||||||
): Job {
|
|
||||||
return owner.lifecycleScope.launch {
|
|
||||||
producer().fold(
|
|
||||||
onSuccess = { onSuccess.accept(it) },
|
|
||||||
onFailure = { onError.accept(it) }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -18,7 +18,7 @@
|
||||||
package com.keylesspalace.tusky.util
|
package com.keylesspalace.tusky.util
|
||||||
|
|
||||||
import android.content.SharedPreferences
|
import android.content.SharedPreferences
|
||||||
import com.keylesspalace.tusky.db.AccountEntity
|
import com.keylesspalace.tusky.db.entity.AccountEntity
|
||||||
import com.keylesspalace.tusky.settings.PrefKeys
|
import com.keylesspalace.tusky.settings.PrefKeys
|
||||||
|
|
||||||
data class StatusDisplayOptions(
|
data class StatusDisplayOptions(
|
||||||
|
|
|
@ -36,10 +36,8 @@ package com.keylesspalace.tusky.util
|
||||||
|
|
||||||
import androidx.paging.CombinedLoadStates
|
import androidx.paging.CombinedLoadStates
|
||||||
import androidx.paging.LoadState
|
import androidx.paging.LoadState
|
||||||
import com.keylesspalace.tusky.entity.Notification
|
|
||||||
import com.keylesspalace.tusky.entity.Status
|
import com.keylesspalace.tusky.entity.Status
|
||||||
import com.keylesspalace.tusky.entity.TrendingTag
|
import com.keylesspalace.tusky.entity.TrendingTag
|
||||||
import com.keylesspalace.tusky.viewdata.NotificationViewData
|
|
||||||
import com.keylesspalace.tusky.viewdata.StatusViewData
|
import com.keylesspalace.tusky.viewdata.StatusViewData
|
||||||
import com.keylesspalace.tusky.viewdata.TranslationViewData
|
import com.keylesspalace.tusky.viewdata.TranslationViewData
|
||||||
import com.keylesspalace.tusky.viewdata.TrendingViewData
|
import com.keylesspalace.tusky.viewdata.TrendingViewData
|
||||||
|
@ -61,21 +59,6 @@ fun Status.toViewData(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@JvmName("notificationToViewData")
|
|
||||||
fun Notification.toViewData(
|
|
||||||
isShowingContent: Boolean,
|
|
||||||
isExpanded: Boolean,
|
|
||||||
isCollapsed: Boolean
|
|
||||||
): NotificationViewData.Concrete {
|
|
||||||
return NotificationViewData.Concrete(
|
|
||||||
this.type,
|
|
||||||
this.id,
|
|
||||||
this.account,
|
|
||||||
this.status?.toViewData(isShowingContent, isExpanded, isCollapsed),
|
|
||||||
this.report
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun List<TrendingTag>.toViewData(): List<TrendingViewData.Tag> {
|
fun List<TrendingTag>.toViewData(): List<TrendingViewData.Tag> {
|
||||||
val maxTrendingValue = flatMap { tag -> tag.history }
|
val maxTrendingValue = flatMap { tag -> tag.history }
|
||||||
.mapNotNull { it.uses.toLongOrNull() }
|
.mapNotNull { it.uses.toLongOrNull() }
|
||||||
|
|
|
@ -1,138 +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.viewdata;
|
|
||||||
|
|
||||||
import androidx.annotation.Nullable;
|
|
||||||
|
|
||||||
import com.keylesspalace.tusky.entity.Notification;
|
|
||||||
import com.keylesspalace.tusky.entity.Report;
|
|
||||||
import com.keylesspalace.tusky.entity.TimelineAccount;
|
|
||||||
|
|
||||||
import java.util.Objects;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Created by charlag on 12/07/2017.
|
|
||||||
* <p>
|
|
||||||
* Class to represent data required to display either a notification or a placeholder.
|
|
||||||
* It is either a {@link Placeholder} or a {@link Concrete}.
|
|
||||||
* It is modelled this way because close relationship between placeholder and concrete notification
|
|
||||||
* is fine in this case. Placeholder case is not modelled as a type of notification because
|
|
||||||
* invariants would be violated and because it would model domain incorrectly. It is preferable to
|
|
||||||
* {@link com.keylesspalace.tusky.util.Either} because class hierarchy is cheaper, faster and
|
|
||||||
* more native.
|
|
||||||
*/
|
|
||||||
public abstract class NotificationViewData {
|
|
||||||
private NotificationViewData() {
|
|
||||||
}
|
|
||||||
|
|
||||||
public abstract long getViewDataId();
|
|
||||||
|
|
||||||
public abstract boolean deepEquals(NotificationViewData other);
|
|
||||||
|
|
||||||
public static final class Concrete extends NotificationViewData {
|
|
||||||
private final Notification.Type type;
|
|
||||||
private final String id;
|
|
||||||
private final TimelineAccount account;
|
|
||||||
@Nullable
|
|
||||||
private final StatusViewData.Concrete statusViewData;
|
|
||||||
@Nullable
|
|
||||||
private final Report report;
|
|
||||||
|
|
||||||
public Concrete(Notification.Type type, String id, TimelineAccount account,
|
|
||||||
@Nullable StatusViewData.Concrete statusViewData, @Nullable Report report) {
|
|
||||||
this.type = type;
|
|
||||||
this.id = id;
|
|
||||||
this.account = account;
|
|
||||||
this.statusViewData = statusViewData;
|
|
||||||
this.report = report;
|
|
||||||
}
|
|
||||||
|
|
||||||
public Notification.Type getType() {
|
|
||||||
return type;
|
|
||||||
}
|
|
||||||
|
|
||||||
public String getId() {
|
|
||||||
return id;
|
|
||||||
}
|
|
||||||
|
|
||||||
public TimelineAccount getAccount() {
|
|
||||||
return account;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Nullable
|
|
||||||
public StatusViewData.Concrete getStatusViewData() {
|
|
||||||
return statusViewData;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Nullable
|
|
||||||
public Report getReport() {
|
|
||||||
return report;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public long getViewDataId() {
|
|
||||||
return id.hashCode();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public boolean deepEquals(NotificationViewData o) {
|
|
||||||
if (this == o) return true;
|
|
||||||
if (o == null || getClass() != o.getClass()) return false;
|
|
||||||
Concrete concrete = (Concrete) o;
|
|
||||||
return type == concrete.type &&
|
|
||||||
Objects.equals(id, concrete.id) &&
|
|
||||||
account.getId().equals(concrete.account.getId()) &&
|
|
||||||
(Objects.equals(statusViewData, concrete.statusViewData)) &&
|
|
||||||
(Objects.equals(report, concrete.report));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public int hashCode() {
|
|
||||||
|
|
||||||
return Objects.hash(type, id, account, statusViewData);
|
|
||||||
}
|
|
||||||
|
|
||||||
public Concrete copyWithStatus(@Nullable StatusViewData.Concrete statusViewData) {
|
|
||||||
return new Concrete(type, id, account, statusViewData, report);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public static final class Placeholder extends NotificationViewData {
|
|
||||||
private final long id;
|
|
||||||
private final boolean isLoading;
|
|
||||||
|
|
||||||
public Placeholder(long id, boolean isLoading) {
|
|
||||||
this.id = id;
|
|
||||||
this.isLoading = isLoading;
|
|
||||||
}
|
|
||||||
|
|
||||||
public boolean isLoading() {
|
|
||||||
return isLoading;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public long getViewDataId() {
|
|
||||||
return id;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public boolean deepEquals(NotificationViewData other) {
|
|
||||||
if (!(other instanceof Placeholder)) return false;
|
|
||||||
Placeholder that = (Placeholder) other;
|
|
||||||
return isLoading == that.isLoading && id == that.id;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -0,0 +1,48 @@
|
||||||
|
/* Copyright 2023 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.viewdata
|
||||||
|
|
||||||
|
import com.keylesspalace.tusky.entity.Notification
|
||||||
|
import com.keylesspalace.tusky.entity.Report
|
||||||
|
import com.keylesspalace.tusky.entity.TimelineAccount
|
||||||
|
|
||||||
|
sealed class NotificationViewData {
|
||||||
|
|
||||||
|
abstract val id: String
|
||||||
|
|
||||||
|
abstract fun asStatusOrNull(): StatusViewData.Concrete?
|
||||||
|
abstract fun asPlaceholderOrNull(): Placeholder?
|
||||||
|
|
||||||
|
data class Concrete(
|
||||||
|
override val id: String,
|
||||||
|
val type: Notification.Type,
|
||||||
|
val account: TimelineAccount,
|
||||||
|
val statusViewData: StatusViewData.Concrete?,
|
||||||
|
val report: Report?
|
||||||
|
) : NotificationViewData() {
|
||||||
|
override fun asStatusOrNull() = statusViewData
|
||||||
|
|
||||||
|
override fun asPlaceholderOrNull() = null
|
||||||
|
}
|
||||||
|
|
||||||
|
data class Placeholder(
|
||||||
|
override val id: String,
|
||||||
|
val isLoading: Boolean
|
||||||
|
) : NotificationViewData() {
|
||||||
|
override fun asStatusOrNull() = null
|
||||||
|
|
||||||
|
override fun asPlaceholderOrNull() = this
|
||||||
|
}
|
||||||
|
}
|
|
@ -103,21 +103,6 @@ sealed class StatusViewData {
|
||||||
val rebloggingStatus: Status?
|
val rebloggingStatus: Status?
|
||||||
get() = if (status.reblog != null) status else null
|
get() = if (status.reblog != null) status else null
|
||||||
|
|
||||||
/** Helper for Java */
|
|
||||||
fun copyWithStatus(status: Status): Concrete {
|
|
||||||
return copy(status = status)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Helper for Java */
|
|
||||||
fun copyWithExpanded(isExpanded: Boolean): Concrete {
|
|
||||||
return copy(isExpanded = isExpanded)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Helper for Java */
|
|
||||||
fun copyWithShowingContent(isShowingContent: Boolean): Concrete {
|
|
||||||
return copy(isShowingContent = isShowingContent)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Helper for Java */
|
/** Helper for Java */
|
||||||
fun copyWithCollapsed(isCollapsed: Boolean): Concrete {
|
fun copyWithCollapsed(isCollapsed: Boolean): Concrete {
|
||||||
return copy(isCollapsed = isCollapsed)
|
return copy(isCollapsed = isCollapsed)
|
||||||
|
|
|
@ -23,9 +23,9 @@ import androidx.work.CoroutineWorker
|
||||||
import androidx.work.ForegroundInfo
|
import androidx.work.ForegroundInfo
|
||||||
import androidx.work.WorkerParameters
|
import androidx.work.WorkerParameters
|
||||||
import com.keylesspalace.tusky.R
|
import com.keylesspalace.tusky.R
|
||||||
import com.keylesspalace.tusky.components.notifications.NotificationFetcher
|
import com.keylesspalace.tusky.components.systemnotifications.NotificationFetcher
|
||||||
import com.keylesspalace.tusky.components.notifications.NotificationHelper
|
import com.keylesspalace.tusky.components.systemnotifications.NotificationHelper
|
||||||
import com.keylesspalace.tusky.components.notifications.NotificationHelper.NOTIFICATION_ID_FETCH_NOTIFICATION
|
import com.keylesspalace.tusky.components.systemnotifications.NotificationHelper.NOTIFICATION_ID_FETCH_NOTIFICATION
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
/** Fetch and show new notifications. */
|
/** Fetch and show new notifications. */
|
||||||
|
|
|
@ -25,17 +25,17 @@ import androidx.work.ForegroundInfo
|
||||||
import androidx.work.ListenableWorker
|
import androidx.work.ListenableWorker
|
||||||
import androidx.work.WorkerParameters
|
import androidx.work.WorkerParameters
|
||||||
import com.keylesspalace.tusky.R
|
import com.keylesspalace.tusky.R
|
||||||
import com.keylesspalace.tusky.components.notifications.NotificationHelper
|
import com.keylesspalace.tusky.components.systemnotifications.NotificationHelper
|
||||||
import com.keylesspalace.tusky.components.notifications.NotificationHelper.NOTIFICATION_ID_PRUNE_CACHE
|
import com.keylesspalace.tusky.components.systemnotifications.NotificationHelper.NOTIFICATION_ID_PRUNE_CACHE
|
||||||
import com.keylesspalace.tusky.db.AccountManager
|
import com.keylesspalace.tusky.db.AccountManager
|
||||||
import com.keylesspalace.tusky.db.AppDatabase
|
import com.keylesspalace.tusky.db.DatabaseCleaner
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
/** Prune the database cache of old statuses. */
|
/** Prune the database cache of old statuses. */
|
||||||
class PruneCacheWorker(
|
class PruneCacheWorker(
|
||||||
appContext: Context,
|
appContext: Context,
|
||||||
workerParams: WorkerParameters,
|
workerParams: WorkerParameters,
|
||||||
private val appDatabase: AppDatabase,
|
private val databaseCleaner: DatabaseCleaner,
|
||||||
private val accountManager: AccountManager
|
private val accountManager: AccountManager
|
||||||
) : CoroutineWorker(appContext, workerParams) {
|
) : CoroutineWorker(appContext, workerParams) {
|
||||||
val notification: Notification = NotificationHelper.createWorkerNotification(
|
val notification: Notification = NotificationHelper.createWorkerNotification(
|
||||||
|
@ -46,7 +46,7 @@ class PruneCacheWorker(
|
||||||
override suspend fun doWork(): Result {
|
override suspend fun doWork(): Result {
|
||||||
for (account in accountManager.accounts) {
|
for (account in accountManager.accounts) {
|
||||||
Log.d(TAG, "Pruning database using account ID: ${account.id}")
|
Log.d(TAG, "Pruning database using account ID: ${account.id}")
|
||||||
appDatabase.timelineDao().cleanup(account.id, MAX_STATUSES_IN_CACHE)
|
databaseCleaner.cleanupOldData(account.id, MAX_HOMETIMELINE_ITEMS_IN_CACHE, MAX_NOTIFICATIONS_IN_CACHE)
|
||||||
}
|
}
|
||||||
return Result.success()
|
return Result.success()
|
||||||
}
|
}
|
||||||
|
@ -58,16 +58,17 @@ class PruneCacheWorker(
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private const val TAG = "PruneCacheWorker"
|
private const val TAG = "PruneCacheWorker"
|
||||||
private const val MAX_STATUSES_IN_CACHE = 1000
|
private const val MAX_HOMETIMELINE_ITEMS_IN_CACHE = 1000
|
||||||
|
private const val MAX_NOTIFICATIONS_IN_CACHE = 1000
|
||||||
const val PERIODIC_WORK_TAG = "PruneCacheWorker_periodic"
|
const val PERIODIC_WORK_TAG = "PruneCacheWorker_periodic"
|
||||||
}
|
}
|
||||||
|
|
||||||
class Factory @Inject constructor(
|
class Factory @Inject constructor(
|
||||||
private val appDatabase: AppDatabase,
|
private val databaseCleaner: DatabaseCleaner,
|
||||||
private val accountManager: AccountManager
|
private val accountManager: AccountManager
|
||||||
) : ChildWorkerFactory {
|
) : ChildWorkerFactory {
|
||||||
override fun createWorker(appContext: Context, params: WorkerParameters): ListenableWorker {
|
override fun createWorker(appContext: Context, params: WorkerParameters): ListenableWorker {
|
||||||
return PruneCacheWorker(appContext, params, appDatabase, accountManager)
|
return PruneCacheWorker(appContext, params, databaseCleaner, accountManager)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,20 +1,19 @@
|
||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
xmlns:tools="http://schemas.android.com/tools"
|
|
||||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||||
|
xmlns:tools="http://schemas.android.com/tools"
|
||||||
android:id="@+id/notification_report"
|
android:id="@+id/notification_report"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:orientation="vertical"
|
android:orientation="vertical"
|
||||||
android:paddingLeft="14dp"
|
android:paddingStart="14dp"
|
||||||
android:paddingRight="14dp">
|
android:paddingEnd="14dp"
|
||||||
|
android:paddingBottom="14dp">
|
||||||
|
|
||||||
<TextView
|
<TextView
|
||||||
android:id="@+id/notification_top_text"
|
android:id="@+id/notification_top_text"
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="wrap_content"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
app:layout_constraintTop_toTopOf="parent"
|
|
||||||
app:layout_constraintLeft_toLeftOf="parent"
|
|
||||||
android:layout_marginTop="8dp"
|
android:layout_marginTop="8dp"
|
||||||
android:drawablePadding="10dp"
|
android:drawablePadding="10dp"
|
||||||
android:ellipsize="end"
|
android:ellipsize="end"
|
||||||
|
@ -23,19 +22,23 @@
|
||||||
android:paddingStart="28dp"
|
android:paddingStart="28dp"
|
||||||
android:textColor="?android:textColorSecondary"
|
android:textColor="?android:textColorSecondary"
|
||||||
android:textSize="?attr/status_text_medium"
|
android:textSize="?attr/status_text_medium"
|
||||||
|
app:drawableStartCompat="@drawable/ic_flag_24dp"
|
||||||
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
|
app:layout_constraintTop_toTopOf="parent"
|
||||||
tools:text="Someone reported someone else" />
|
tools:text="Someone reported someone else" />
|
||||||
|
|
||||||
<ImageView
|
<ImageView
|
||||||
android:id="@+id/notification_reportee_avatar"
|
android:id="@+id/notification_reportee_avatar"
|
||||||
android:layout_width="48dp"
|
android:layout_width="48dp"
|
||||||
android:layout_height="48dp"
|
android:layout_height="48dp"
|
||||||
app:layout_constraintTop_toBottomOf="@id/notification_top_text"
|
|
||||||
app:layout_constraintLeft_toLeftOf="parent"
|
|
||||||
android:layout_marginTop="10dp"
|
android:layout_marginTop="10dp"
|
||||||
android:layout_marginEnd="14dp"
|
android:layout_marginEnd="14dp"
|
||||||
android:layout_marginBottom="14dp"
|
|
||||||
android:contentDescription="@string/action_view_profile"
|
android:contentDescription="@string/action_view_profile"
|
||||||
|
android:paddingEnd="12dp"
|
||||||
|
android:paddingBottom="12dp"
|
||||||
android:scaleType="centerCrop"
|
android:scaleType="centerCrop"
|
||||||
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
|
app:layout_constraintTop_toBottomOf="@id/notification_top_text"
|
||||||
tools:ignore="RtlHardcoded,RtlSymmetry"
|
tools:ignore="RtlHardcoded,RtlSymmetry"
|
||||||
tools:src="@drawable/avatar_default" />
|
tools:src="@drawable/avatar_default" />
|
||||||
|
|
||||||
|
@ -44,40 +47,38 @@
|
||||||
android:layout_width="24dp"
|
android:layout_width="24dp"
|
||||||
android:layout_height="24dp"
|
android:layout_height="24dp"
|
||||||
android:contentDescription="@string/action_view_profile"
|
android:contentDescription="@string/action_view_profile"
|
||||||
app:layout_constraintRight_toRightOf="@id/notification_reportee_avatar"
|
|
||||||
app:layout_constraintBottom_toBottomOf="@id/notification_reportee_avatar"
|
app:layout_constraintBottom_toBottomOf="@id/notification_reportee_avatar"
|
||||||
/>
|
app:layout_constraintEnd_toEndOf="@id/notification_reportee_avatar"
|
||||||
|
tools:src="@drawable/avatar_default" />
|
||||||
|
|
||||||
<TextView
|
<TextView
|
||||||
android:id="@+id/notification_summary"
|
android:id="@+id/notification_summary"
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="wrap_content"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_marginTop="-4dp"
|
|
||||||
android:layout_marginStart="14dp"
|
android:layout_marginStart="14dp"
|
||||||
app:layout_constraintTop_toTopOf="@id/notification_reportee_avatar"
|
android:layout_marginTop="6dp"
|
||||||
app:layout_constraintLeft_toRightOf="@id/notification_reporter_avatar"
|
|
||||||
android:importantForAccessibility="no"
|
|
||||||
android:hyphenationFrequency="full"
|
android:hyphenationFrequency="full"
|
||||||
|
android:importantForAccessibility="no"
|
||||||
android:lineSpacingMultiplier="1.1"
|
android:lineSpacingMultiplier="1.1"
|
||||||
android:textColor="?android:textColorTertiary"
|
android:textColor="?android:textColorTertiary"
|
||||||
android:textSize="?attr/status_text_medium"
|
android:textSize="?attr/status_text_medium"
|
||||||
tools:ignore="NegativeMargin"
|
app:layout_constraintStart_toEndOf="@id/notification_reporter_avatar"
|
||||||
tools:text="30 minutes ago - 2 posts" />
|
app:layout_constraintTop_toBottomOf="@id/notification_top_text"
|
||||||
|
tools:text="30 minutes ago · 2 posts attached" />
|
||||||
|
|
||||||
<TextView
|
<TextView
|
||||||
android:id="@+id/notification_category"
|
android:id="@+id/notification_category"
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="wrap_content"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_marginStart="14dp"
|
android:layout_marginStart="14dp"
|
||||||
app:layout_constraintTop_toBottomOf="@id/notification_summary"
|
|
||||||
app:layout_constraintLeft_toRightOf="@id/notification_reporter_avatar"
|
|
||||||
android:importantForAccessibility="no"
|
|
||||||
android:hyphenationFrequency="full"
|
android:hyphenationFrequency="full"
|
||||||
|
android:importantForAccessibility="no"
|
||||||
android:lineSpacingMultiplier="1.1"
|
android:lineSpacingMultiplier="1.1"
|
||||||
android:paddingBottom="10dp"
|
|
||||||
android:textColor="?android:textColorTertiary"
|
android:textColor="?android:textColorTertiary"
|
||||||
android:textSize="?attr/status_text_medium"
|
android:textSize="?attr/status_text_medium"
|
||||||
android:textStyle="bold"
|
android:textStyle="bold"
|
||||||
|
app:layout_constraintStart_toEndOf="@id/notification_reporter_avatar"
|
||||||
|
app:layout_constraintTop_toBottomOf="@id/notification_summary"
|
||||||
tools:text="Spam" />
|
tools:text="Spam" />
|
||||||
|
|
||||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||||
|
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue