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"?>
|
||||
<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
|
||||
id="GestureBackNavigation"
|
||||
|
@ -53,14 +53,14 @@
|
|||
|
||||
<issue
|
||||
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
|
||||
file="src/main/res/layout/exo_player_control_view.xml"/>
|
||||
</issue>
|
||||
|
||||
<issue
|
||||
id="PrivateResource"
|
||||
message="The resource `@color/exo_bottom_bar_background` is marked as private in androidx.media3:media3-ui:1.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""
|
||||
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
|
||||
<location
|
||||
|
@ -71,7 +71,7 @@
|
|||
|
||||
<issue
|
||||
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""
|
||||
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
|
||||
<location
|
||||
|
@ -82,7 +82,7 @@
|
|||
|
||||
<issue
|
||||
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" />"
|
||||
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
|
||||
<location
|
||||
|
@ -93,7 +93,7 @@
|
|||
|
||||
<issue
|
||||
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" />"
|
||||
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
|
||||
<location
|
||||
|
@ -104,7 +104,7 @@
|
|||
|
||||
<issue
|
||||
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""
|
||||
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
|
||||
<location
|
||||
|
@ -115,7 +115,7 @@
|
|||
|
||||
<issue
|
||||
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""
|
||||
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
|
||||
<location
|
||||
|
@ -126,7 +126,7 @@
|
|||
|
||||
<issue
|
||||
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""
|
||||
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
|
||||
<location
|
||||
|
@ -137,7 +137,7 @@
|
|||
|
||||
<issue
|
||||
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""
|
||||
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
|
||||
<location
|
||||
|
@ -148,7 +148,7 @@
|
|||
|
||||
<issue
|
||||
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""
|
||||
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
|
||||
<location
|
||||
|
@ -159,7 +159,7 @@
|
|||
|
||||
<issue
|
||||
id="PrivateResource"
|
||||
message="The resource `@dimen/exo_styled_bottom_bar_time_padding` is marked as private in androidx.media3:media3-ui:1.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""
|
||||
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
|
||||
<location
|
||||
|
@ -170,7 +170,7 @@
|
|||
|
||||
<issue
|
||||
id="PrivateResource"
|
||||
message="The resource `@dimen/exo_styled_bottom_bar_time_padding` is marked as private in androidx.media3:media3-ui:1.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""
|
||||
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
|
||||
<location
|
||||
|
@ -181,7 +181,7 @@
|
|||
|
||||
<issue
|
||||
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""
|
||||
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
|
||||
<location
|
||||
|
@ -192,7 +192,7 @@
|
|||
|
||||
<issue
|
||||
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"/>"
|
||||
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
|
||||
<location
|
||||
|
@ -203,7 +203,7 @@
|
|||
|
||||
<issue
|
||||
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""
|
||||
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
|
||||
<location
|
||||
|
@ -498,28 +498,6 @@
|
|||
column="13"/>
|
||||
</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
|
||||
id="SmallSp"
|
||||
message="Avoid using sizes smaller than `11sp`: `8sp`"
|
||||
|
@ -905,279 +883,4 @@
|
|||
column="9"/>
|
||||
</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>
|
||||
|
|
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.keylesspalace.tusky.adapter.AccountSelectionAdapter;
|
||||
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.di.Injectable;
|
||||
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.drafts.DraftsActivity
|
||||
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.scheduled.ScheduledStatusActivity
|
||||
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.databinding.ActivityMainBinding
|
||||
import com.keylesspalace.tusky.db.AccountEntity
|
||||
import com.keylesspalace.tusky.db.DraftsAlert
|
||||
import com.keylesspalace.tusky.db.entity.AccountEntity
|
||||
import com.keylesspalace.tusky.di.ApplicationScope
|
||||
import com.keylesspalace.tusky.entity.Account
|
||||
import com.keylesspalace.tusky.entity.Notification
|
||||
|
|
|
@ -20,10 +20,10 @@ import androidx.annotation.DrawableRes
|
|||
import androidx.annotation.StringRes
|
||||
import androidx.fragment.app.Fragment
|
||||
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.viewmodel.TimelineViewModel
|
||||
import com.keylesspalace.tusky.components.trending.TrendingTagsFragment
|
||||
import com.keylesspalace.tusky.fragment.NotificationsFragment
|
||||
import java.util.Objects
|
||||
|
||||
/** 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.PeriodicWorkRequestBuilder
|
||||
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.settings.AppTheme
|
||||
import com.keylesspalace.tusky.settings.NEW_INSTALL_SCHEMA_VERSION
|
||||
|
@ -127,12 +127,6 @@ class TuskyApplication : Application(), HasAndroidInjector {
|
|||
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) {
|
||||
// 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
|
||||
|
|
|
@ -23,7 +23,7 @@ import android.widget.ArrayAdapter
|
|||
import androidx.preference.PreferenceManager
|
||||
import com.keylesspalace.tusky.R
|
||||
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.util.emojify
|
||||
import com.keylesspalace.tusky.util.loadAvatar
|
||||
|
|
|
@ -21,10 +21,12 @@ import android.text.Spanned
|
|||
import android.text.style.StyleSpan
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.keylesspalace.tusky.R
|
||||
import com.keylesspalace.tusky.components.notifications.NotificationsViewHolder
|
||||
import com.keylesspalace.tusky.databinding.ItemFollowRequestBinding
|
||||
import com.keylesspalace.tusky.entity.TimelineAccount
|
||||
import com.keylesspalace.tusky.interfaces.AccountActionListener
|
||||
import com.keylesspalace.tusky.interfaces.LinkListener
|
||||
import com.keylesspalace.tusky.util.StatusDisplayOptions
|
||||
import com.keylesspalace.tusky.util.emojify
|
||||
import com.keylesspalace.tusky.util.hide
|
||||
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.unicodeWrap
|
||||
import com.keylesspalace.tusky.util.visible
|
||||
import com.keylesspalace.tusky.viewdata.NotificationViewData
|
||||
|
||||
class FollowRequestViewHolder(
|
||||
private val binding: ItemFollowRequestBinding,
|
||||
private val accountListener: AccountActionListener,
|
||||
private val linkListener: LinkListener,
|
||||
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(
|
||||
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>. */
|
||||
package com.keylesspalace.tusky.adapter
|
||||
|
||||
import android.view.View
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.google.android.material.button.MaterialButton
|
||||
import com.google.android.material.progressindicator.CircularProgressIndicatorSpec
|
||||
import com.google.android.material.progressindicator.IndeterminateDrawable
|
||||
import com.keylesspalace.tusky.R
|
||||
import com.keylesspalace.tusky.databinding.ItemStatusPlaceholderBinding
|
||||
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
|
||||
* circular progress wheel if the status' 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.
|
||||
* Displays a "Load more" button to load the gap, or a
|
||||
* circular progress bar if the missing page is being loaded.
|
||||
*/
|
||||
class PlaceholderViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
|
||||
private val loadMoreButton: MaterialButton = itemView.findViewById(R.id.button_load_more)
|
||||
private val drawable = IndeterminateDrawable.createCircularDrawable(
|
||||
itemView.context,
|
||||
CircularProgressIndicatorSpec(itemView.context, null)
|
||||
)
|
||||
class PlaceholderViewHolder(
|
||||
private val binding: ItemStatusPlaceholderBinding,
|
||||
listener: StatusActionListener
|
||||
) : RecyclerView.ViewHolder(binding.root) {
|
||||
|
||||
fun setup(listener: StatusActionListener, loading: Boolean) {
|
||||
itemView.isEnabled = !loading
|
||||
loadMoreButton.isEnabled = !loading
|
||||
|
||||
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 = ""
|
||||
init {
|
||||
binding.loadMoreButton.setOnClickListener {
|
||||
binding.loadMoreButton.hide()
|
||||
binding.loadMoreProgressBar.show()
|
||||
listener.onLoadMore(bindingAdapterPosition)
|
||||
}
|
||||
}
|
||||
|
||||
fun setup(loading: Boolean) {
|
||||
binding.loadMoreButton.visible(!loading)
|
||||
binding.loadMoreProgressBar.visible(loading)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -2,6 +2,9 @@ package com.keylesspalace.tusky.appstore
|
|||
|
||||
import com.keylesspalace.tusky.db.AccountManager
|
||||
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 kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
|
@ -9,40 +12,64 @@ import kotlinx.coroutines.SupervisorJob
|
|||
import kotlinx.coroutines.cancel
|
||||
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(
|
||||
eventHub: EventHub,
|
||||
accountManager: AccountManager,
|
||||
appDatabase: AppDatabase
|
||||
appDatabase: AppDatabase,
|
||||
moshi: Moshi
|
||||
) {
|
||||
|
||||
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||
|
||||
init {
|
||||
val timelineDao = appDatabase.timelineDao()
|
||||
private val timelineDao = appDatabase.timelineDao()
|
||||
private val statusDao = appDatabase.timelineStatusDao()
|
||||
private val notificationsDao = appDatabase.notificationsDao()
|
||||
|
||||
init {
|
||||
scope.launch {
|
||||
eventHub.events.collect { event ->
|
||||
val accountId = accountManager.activeAccount?.id ?: return@collect
|
||||
val tuskyAccountId = accountManager.activeAccount?.id ?: return@collect
|
||||
when (event) {
|
||||
is StatusChangedEvent -> {
|
||||
val status = event.status
|
||||
timelineDao.update(
|
||||
accountId = accountId,
|
||||
status = status
|
||||
)
|
||||
is StatusChangedEvent -> statusDao.update(
|
||||
tuskyAccountId = tuskyAccountId,
|
||||
status = event.status,
|
||||
moshi = moshi
|
||||
)
|
||||
|
||||
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 ->
|
||||
timelineDao.delete(accountId, event.statusId)
|
||||
|
||||
is StatusDeletedEvent -> {
|
||||
timelineDao.deleteAllWithStatus(tuskyAccountId, event.statusId)
|
||||
notificationsDao.deleteAllWithStatus(tuskyAccountId, event.statusId)
|
||||
}
|
||||
|
||||
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() {
|
||||
this.scope.cancel()
|
||||
}
|
||||
|
|
|
@ -1,14 +1,10 @@
|
|||
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.Singleton
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
interface Event
|
||||
|
||||
|
@ -21,13 +17,4 @@ class EventHub @Inject constructor() {
|
|||
suspend fun dispatch(event: 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.report.ReportActivity
|
||||
import com.keylesspalace.tusky.databinding.ActivityAccountBinding
|
||||
import com.keylesspalace.tusky.db.AccountEntity
|
||||
import com.keylesspalace.tusky.db.DraftsAlert
|
||||
import com.keylesspalace.tusky.db.entity.AccountEntity
|
||||
import com.keylesspalace.tusky.di.ViewModelFactory
|
||||
import com.keylesspalace.tusky.entity.Account
|
||||
import com.keylesspalace.tusky.entity.Relationship
|
||||
|
|
|
@ -20,7 +20,7 @@ import androidx.paging.LoadType
|
|||
import androidx.paging.PagingState
|
||||
import androidx.paging.RemoteMediator
|
||||
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.viewdata.AttachmentViewData
|
||||
import retrofit2.HttpException
|
||||
|
|
|
@ -44,6 +44,7 @@ class FollowRequestsAdapter(
|
|||
)
|
||||
return FollowRequestViewHolder(
|
||||
binding,
|
||||
accountActionListener,
|
||||
linkListener,
|
||||
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.instanceinfo.InstanceInfoRepository
|
||||
import com.keylesspalace.tusky.databinding.ActivityComposeBinding
|
||||
import com.keylesspalace.tusky.db.AccountEntity
|
||||
import com.keylesspalace.tusky.db.DraftAttachment
|
||||
import com.keylesspalace.tusky.db.entity.AccountEntity
|
||||
import com.keylesspalace.tusky.db.entity.DraftAttachment
|
||||
import com.keylesspalace.tusky.di.Injectable
|
||||
import com.keylesspalace.tusky.di.ViewModelFactory
|
||||
import com.keylesspalace.tusky.entity.Attachment
|
||||
|
|
|
@ -126,7 +126,7 @@ data class ConversationStatusEntity(
|
|||
visibility = Status.Visibility.DIRECT,
|
||||
attachments = attachments,
|
||||
mentions = mentions,
|
||||
tags = tags,
|
||||
tags = tags.orEmpty(),
|
||||
application = null,
|
||||
pinned = false,
|
||||
muted = muted,
|
||||
|
@ -148,7 +148,7 @@ fun TimelineAccount.toEntity() = ConversationAccountEntity(
|
|||
username = username,
|
||||
displayName = name,
|
||||
avatar = avatar,
|
||||
emojis = emojis.orEmpty()
|
||||
emojis = emojis
|
||||
)
|
||||
|
||||
fun Status.toEntity(expanded: Boolean, contentShowing: Boolean, contentCollapsed: Boolean) =
|
||||
|
|
|
@ -23,8 +23,8 @@ import androidx.core.content.FileProvider
|
|||
import androidx.core.net.toUri
|
||||
import com.keylesspalace.tusky.BuildConfig
|
||||
import com.keylesspalace.tusky.db.AppDatabase
|
||||
import com.keylesspalace.tusky.db.DraftAttachment
|
||||
import com.keylesspalace.tusky.db.DraftEntity
|
||||
import com.keylesspalace.tusky.db.entity.DraftAttachment
|
||||
import com.keylesspalace.tusky.db.entity.DraftEntity
|
||||
import com.keylesspalace.tusky.entity.Attachment
|
||||
import com.keylesspalace.tusky.entity.NewPoll
|
||||
import com.keylesspalace.tusky.entity.Status
|
||||
|
|
|
@ -24,7 +24,7 @@ import androidx.recyclerview.widget.RecyclerView
|
|||
import com.bumptech.glide.Glide
|
||||
import com.bumptech.glide.load.engine.DiskCacheStrategy
|
||||
import com.keylesspalace.tusky.R
|
||||
import com.keylesspalace.tusky.db.DraftAttachment
|
||||
import com.keylesspalace.tusky.db.entity.DraftAttachment
|
||||
import com.keylesspalace.tusky.view.MediaPreviewImageView
|
||||
|
||||
class DraftMediaAdapter(
|
||||
|
|
|
@ -32,8 +32,8 @@ import com.keylesspalace.tusky.BaseActivity
|
|||
import com.keylesspalace.tusky.R
|
||||
import com.keylesspalace.tusky.components.compose.ComposeActivity
|
||||
import com.keylesspalace.tusky.databinding.ActivityDraftsBinding
|
||||
import com.keylesspalace.tusky.db.DraftEntity
|
||||
import com.keylesspalace.tusky.db.DraftsAlert
|
||||
import com.keylesspalace.tusky.db.entity.DraftEntity
|
||||
import com.keylesspalace.tusky.di.ViewModelFactory
|
||||
import com.keylesspalace.tusky.util.isHttpNotFound
|
||||
import com.keylesspalace.tusky.util.parseAsMastodonHtml
|
||||
|
|
|
@ -22,7 +22,7 @@ import androidx.recyclerview.widget.DiffUtil
|
|||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
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.hide
|
||||
import com.keylesspalace.tusky.util.show
|
||||
|
|
|
@ -23,7 +23,7 @@ import androidx.paging.cachedIn
|
|||
import at.connyduck.calladapter.networkresult.NetworkResult
|
||||
import com.keylesspalace.tusky.db.AccountManager
|
||||
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.network.MastodonApi
|
||||
import javax.inject.Inject
|
||||
|
|
|
@ -24,8 +24,8 @@ import at.connyduck.calladapter.networkresult.onSuccess
|
|||
import at.connyduck.calladapter.networkresult.recoverCatching
|
||||
import com.keylesspalace.tusky.db.AccountManager
|
||||
import com.keylesspalace.tusky.db.AppDatabase
|
||||
import com.keylesspalace.tusky.db.EmojisEntity
|
||||
import com.keylesspalace.tusky.db.InstanceInfoEntity
|
||||
import com.keylesspalace.tusky.db.entity.EmojisEntity
|
||||
import com.keylesspalace.tusky.db.entity.InstanceInfoEntity
|
||||
import com.keylesspalace.tusky.di.ApplicationScope
|
||||
import com.keylesspalace.tusky.entity.Emoji
|
||||
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,
|
||||
* see <http://www.gnu.org/licenses>. */
|
||||
|
||||
package com.keylesspalace.tusky.adapter
|
||||
package com.keylesspalace.tusky.components.notifications
|
||||
|
||||
import android.content.Context
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import at.connyduck.sparkbutton.helpers.Utils
|
||||
import com.keylesspalace.tusky.R
|
||||
import com.keylesspalace.tusky.adapter.NotificationsAdapter.NotificationActionListener
|
||||
import com.keylesspalace.tusky.databinding.ItemReportNotificationBinding
|
||||
import com.keylesspalace.tusky.entity.Report
|
||||
import com.keylesspalace.tusky.entity.TimelineAccount
|
||||
import com.keylesspalace.tusky.interfaces.AccountActionListener
|
||||
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.unicodeWrap
|
||||
import com.keylesspalace.tusky.viewdata.NotificationViewData
|
||||
|
||||
class ReportNotificationViewHolder(
|
||||
private val binding: ItemReportNotificationBinding
|
||||
) : RecyclerView.ViewHolder(binding.root) {
|
||||
private val binding: ItemReportNotificationBinding,
|
||||
private val listener: NotificationActionListener,
|
||||
private val accountActionListener: AccountActionListener
|
||||
) : RecyclerView.ViewHolder(binding.root), NotificationsViewHolder {
|
||||
|
||||
fun setupWithReport(
|
||||
reporter: TimelineAccount,
|
||||
report: Report,
|
||||
animateAvatar: Boolean,
|
||||
animateEmojis: Boolean
|
||||
override fun bind(
|
||||
viewData: NotificationViewData.Concrete,
|
||||
payloads: List<*>,
|
||||
statusDisplayOptions: StatusDisplayOptions
|
||||
) {
|
||||
val reporterName = reporter.name.unicodeWrap().emojify(
|
||||
reporter.emojis,
|
||||
itemView,
|
||||
animateEmojis
|
||||
)
|
||||
val reporteeName = report.targetAccount.name.unicodeWrap().emojify(
|
||||
report.targetAccount.emojis,
|
||||
itemView,
|
||||
animateEmojis
|
||||
)
|
||||
val icon = ContextCompat.getDrawable(itemView.context, R.drawable.ic_flag_24dp)
|
||||
val report = viewData.report!!
|
||||
val reporter = viewData.account
|
||||
|
||||
val reporterName = reporter.name.unicodeWrap().emojify(reporter.emojis, itemView, statusDisplayOptions.animateEmojis)
|
||||
val reporteeName = report.targetAccount.name.unicodeWrap().emojify(report.targetAccount.emojis, itemView, statusDisplayOptions.animateEmojis)
|
||||
|
||||
binding.notificationTopText.setCompoundDrawablesWithIntrinsicBounds(icon, null, null, null)
|
||||
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.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(
|
||||
report.targetAccount.avatar,
|
||||
binding.notificationReporteeAvatar,
|
||||
itemView.context.resources.getDimensionPixelSize(R.dimen.avatar_radius_36dp),
|
||||
animateAvatar
|
||||
statusDisplayOptions.animateAvatars,
|
||||
)
|
||||
loadAvatar(
|
||||
reporter.avatar,
|
||||
binding.notificationReporterAvatar,
|
||||
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 {
|
||||
val position = bindingAdapterPosition
|
||||
if (position != RecyclerView.NO_POSITION) {
|
||||
listener.onViewAccount(reporteeId)
|
||||
accountActionListener.onViewAccount(report.targetAccount.id)
|
||||
}
|
||||
}
|
||||
binding.notificationReporterAvatar.setOnClickListener {
|
||||
val position = bindingAdapterPosition
|
||||
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 {
|
||||
return when (rawCategory) {
|
||||
"violation" -> context.getString(R.string.report_category_violation)
|
||||
"spam" -> context.getString(R.string.report_category_spam)
|
||||
"legal" -> context.getString(R.string.report_category_legal)
|
||||
"other" -> context.getString(R.string.report_category_other)
|
||||
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.followedtags.FollowedTagsActivity
|
||||
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.di.Injectable
|
||||
import com.keylesspalace.tusky.entity.Account
|
||||
|
|
|
@ -18,9 +18,9 @@ package com.keylesspalace.tusky.components.preference
|
|||
import android.os.Bundle
|
||||
import androidx.preference.PreferenceFragmentCompat
|
||||
import com.keylesspalace.tusky.R
|
||||
import com.keylesspalace.tusky.components.notifications.NotificationHelper
|
||||
import com.keylesspalace.tusky.db.AccountEntity
|
||||
import com.keylesspalace.tusky.components.systemnotifications.NotificationHelper
|
||||
import com.keylesspalace.tusky.db.AccountManager
|
||||
import com.keylesspalace.tusky.db.entity.AccountEntity
|
||||
import com.keylesspalace.tusky.di.Injectable
|
||||
import com.keylesspalace.tusky.settings.PrefKeys
|
||||
import com.keylesspalace.tusky.settings.makePreferenceScreen
|
||||
|
|
|
@ -162,6 +162,13 @@ class PreferencesFragment : PreferenceFragmentCompat(), Injectable {
|
|||
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 {
|
||||
setDefaultValue(false)
|
||||
key = PrefKeys.FAB_HIDE
|
||||
|
|
|
@ -27,8 +27,8 @@ import at.connyduck.calladapter.networkresult.map
|
|||
import at.connyduck.calladapter.networkresult.onFailure
|
||||
import com.keylesspalace.tusky.components.instanceinfo.InstanceInfoRepository
|
||||
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.entity.AccountEntity
|
||||
import com.keylesspalace.tusky.entity.DeletedStatus
|
||||
import com.keylesspalace.tusky.entity.Status
|
||||
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.report.ReportActivity
|
||||
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.entity.AccountEntity
|
||||
import com.keylesspalace.tusky.entity.Attachment
|
||||
import com.keylesspalace.tusky.entity.Status
|
||||
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.content.Context
|
||||
|
@ -6,9 +6,9 @@ import android.util.Log
|
|||
import androidx.annotation.WorkerThread
|
||||
import com.keylesspalace.tusky.appstore.EventHub
|
||||
import com.keylesspalace.tusky.appstore.NewNotificationsEvent
|
||||
import com.keylesspalace.tusky.components.notifications.NotificationHelper.filterNotification
|
||||
import com.keylesspalace.tusky.db.AccountEntity
|
||||
import com.keylesspalace.tusky.components.systemnotifications.NotificationHelper.filterNotification
|
||||
import com.keylesspalace.tusky.db.AccountManager
|
||||
import com.keylesspalace.tusky.db.entity.AccountEntity
|
||||
import com.keylesspalace.tusky.entity.Marker
|
||||
import com.keylesspalace.tusky.entity.Notification
|
||||
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,
|
||||
* 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.util.StatusParsingHelper.parseAsMastodonHtml;
|
||||
|
@ -55,7 +55,7 @@ import com.bumptech.glide.request.FutureTarget;
|
|||
import com.keylesspalace.tusky.MainActivity;
|
||||
import com.keylesspalace.tusky.R;
|
||||
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.entity.Notification;
|
||||
import com.keylesspalace.tusky.entity.Poll;
|
|
@ -15,7 +15,7 @@
|
|||
|
||||
@file:JvmName("PushNotificationHelper")
|
||||
|
||||
package com.keylesspalace.tusky.components.notifications
|
||||
package com.keylesspalace.tusky.components.systemnotifications
|
||||
|
||||
import android.app.NotificationManager
|
||||
import android.content.Context
|
||||
|
@ -29,8 +29,8 @@ import at.connyduck.calladapter.networkresult.onSuccess
|
|||
import com.google.android.material.snackbar.Snackbar
|
||||
import com.keylesspalace.tusky.R
|
||||
import com.keylesspalace.tusky.components.login.LoginActivity
|
||||
import com.keylesspalace.tusky.db.AccountEntity
|
||||
import com.keylesspalace.tusky.db.AccountManager
|
||||
import com.keylesspalace.tusky.db.entity.AccountEntity
|
||||
import com.keylesspalace.tusky.entity.Notification
|
||||
import com.keylesspalace.tusky.network.MastodonApi
|
||||
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.StatusBaseViewHolder
|
||||
import com.keylesspalace.tusky.adapter.StatusViewHolder
|
||||
import com.keylesspalace.tusky.databinding.ItemStatusPlaceholderBinding
|
||||
import com.keylesspalace.tusky.entity.Filter
|
||||
import com.keylesspalace.tusky.interfaces.StatusActionListener
|
||||
import com.keylesspalace.tusky.util.StatusDisplayOptions
|
||||
|
@ -54,7 +55,8 @@ class TimelinePagingAdapter(
|
|||
}
|
||||
VIEW_TYPE_PLACEHOLDER -> {
|
||||
PlaceholderViewHolder(
|
||||
inflater.inflate(R.layout.item_status_placeholder, viewGroup, false)
|
||||
ItemStatusPlaceholderBinding.inflate(inflater, viewGroup, false),
|
||||
statusListener
|
||||
)
|
||||
}
|
||||
else -> {
|
||||
|
@ -83,7 +85,7 @@ class TimelinePagingAdapter(
|
|||
val status = getItem(position)
|
||||
if (status is StatusViewData.Placeholder) {
|
||||
val holder = viewHolder as PlaceholderViewHolder
|
||||
holder.setup(statusListener, status.isLoading)
|
||||
holder.setup(status.isLoading)
|
||||
} else if (status is StatusViewData.Concrete) {
|
||||
val holder = viewHolder as StatusViewHolder
|
||||
holder.setupWithStatus(
|
||||
|
|
|
@ -17,45 +17,36 @@
|
|||
|
||||
package com.keylesspalace.tusky.components.timeline
|
||||
|
||||
import android.util.Log
|
||||
import com.keylesspalace.tusky.db.TimelineAccountEntity
|
||||
import com.keylesspalace.tusky.db.TimelineStatusEntity
|
||||
import com.keylesspalace.tusky.db.TimelineStatusWithAccount
|
||||
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.db.entity.HomeTimelineData
|
||||
import com.keylesspalace.tusky.db.entity.HomeTimelineEntity
|
||||
import com.keylesspalace.tusky.db.entity.TimelineAccountEntity
|
||||
import com.keylesspalace.tusky.db.entity.TimelineStatusEntity
|
||||
import com.keylesspalace.tusky.entity.Status
|
||||
import com.keylesspalace.tusky.entity.TimelineAccount
|
||||
import com.keylesspalace.tusky.viewdata.StatusViewData
|
||||
import com.keylesspalace.tusky.viewdata.TranslationViewData
|
||||
import com.squareup.moshi.Moshi
|
||||
import com.squareup.moshi.adapter
|
||||
import java.util.Date
|
||||
|
||||
private const val TAG = "TimelineTypeMappers"
|
||||
|
||||
data class Placeholder(
|
||||
val id: String,
|
||||
val loading: Boolean
|
||||
)
|
||||
|
||||
fun TimelineAccount.toEntity(accountId: Long, moshi: Moshi): TimelineAccountEntity {
|
||||
fun TimelineAccount.toEntity(tuskyAccountId: Long): TimelineAccountEntity {
|
||||
return TimelineAccountEntity(
|
||||
serverId = id,
|
||||
timelineUserId = accountId,
|
||||
tuskyAccountId = tuskyAccountId,
|
||||
localUsername = localUsername,
|
||||
username = username,
|
||||
displayName = name,
|
||||
url = url,
|
||||
avatar = avatar,
|
||||
emojis = moshi.adapter<List<Emoji>>().toJson(emojis),
|
||||
emojis = emojis,
|
||||
bot = bot
|
||||
)
|
||||
}
|
||||
|
||||
fun TimelineAccountEntity.toAccount(moshi: Moshi): TimelineAccount {
|
||||
fun TimelineAccountEntity.toAccount(): TimelineAccount {
|
||||
return TimelineAccount(
|
||||
id = serverId,
|
||||
localUsername = localUsername,
|
||||
|
@ -65,120 +56,114 @@ fun TimelineAccountEntity.toAccount(moshi: Moshi): TimelineAccount {
|
|||
url = url,
|
||||
avatar = avatar,
|
||||
bot = bot,
|
||||
emojis = moshi.adapter<List<Emoji>?>().fromJson(emojis).orEmpty()
|
||||
emojis = emojis
|
||||
)
|
||||
}
|
||||
|
||||
fun Placeholder.toEntity(timelineUserId: Long): TimelineStatusEntity {
|
||||
return TimelineStatusEntity(
|
||||
serverId = this.id,
|
||||
url = null,
|
||||
timelineUserId = timelineUserId,
|
||||
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,
|
||||
fun Placeholder.toEntity(tuskyAccountId: Long): HomeTimelineEntity {
|
||||
return HomeTimelineEntity(
|
||||
id = this.id,
|
||||
tuskyAccountId = tuskyAccountId,
|
||||
statusId = null,
|
||||
reblogAccountId = null,
|
||||
poll = null,
|
||||
muted = false,
|
||||
expanded = loading,
|
||||
contentCollapsed = false,
|
||||
contentShowing = false,
|
||||
pinned = false,
|
||||
card = null,
|
||||
repliesCount = 0,
|
||||
language = null,
|
||||
filtered = emptyList()
|
||||
loading = this.loading
|
||||
)
|
||||
}
|
||||
|
||||
fun Status.toEntity(
|
||||
timelineUserId: Long,
|
||||
moshi: Moshi,
|
||||
tuskyAccountId: Long,
|
||||
expanded: Boolean,
|
||||
contentShowing: Boolean,
|
||||
contentCollapsed: Boolean
|
||||
): TimelineStatusEntity {
|
||||
return TimelineStatusEntity(
|
||||
serverId = this.id,
|
||||
url = actionableStatus.url,
|
||||
timelineUserId = timelineUserId,
|
||||
authorServerId = actionableStatus.account.id,
|
||||
inReplyToId = actionableStatus.inReplyToId,
|
||||
inReplyToAccountId = actionableStatus.inReplyToAccountId,
|
||||
content = actionableStatus.content,
|
||||
createdAt = actionableStatus.createdAt.time,
|
||||
editedAt = actionableStatus.editedAt?.time,
|
||||
emojis = actionableStatus.emojis.let { moshi.adapter<List<Emoji>>().toJson(it) },
|
||||
reblogsCount = actionableStatus.reblogsCount,
|
||||
favouritesCount = actionableStatus.favouritesCount,
|
||||
reblogged = actionableStatus.reblogged,
|
||||
favourited = actionableStatus.favourited,
|
||||
bookmarked = actionableStatus.bookmarked,
|
||||
sensitive = actionableStatus.sensitive,
|
||||
spoilerText = actionableStatus.spoilerText,
|
||||
visibility = actionableStatus.visibility,
|
||||
attachments = actionableStatus.attachments.let { moshi.adapter<List<Attachment>>().toJson(it) },
|
||||
mentions = actionableStatus.mentions.let { moshi.adapter<List<Status.Mention>>().toJson(it) },
|
||||
tags = actionableStatus.tags.let { moshi.adapter<List<HashTag>?>().toJson(it) },
|
||||
application = actionableStatus.application.let { moshi.adapter<Status.Application?>().toJson(it) },
|
||||
reblogServerId = reblog?.id,
|
||||
reblogAccountId = reblog?.let { this.account.id },
|
||||
poll = actionableStatus.poll.let { moshi.adapter<Poll?>().toJson(it) },
|
||||
muted = actionableStatus.muted,
|
||||
expanded = expanded,
|
||||
contentShowing = contentShowing,
|
||||
contentCollapsed = contentCollapsed,
|
||||
pinned = actionableStatus.pinned,
|
||||
card = actionableStatus.card?.let { moshi.adapter<Card>().toJson(it) },
|
||||
repliesCount = actionableStatus.repliesCount,
|
||||
language = actionableStatus.language,
|
||||
filtered = actionableStatus.filtered
|
||||
)
|
||||
}
|
||||
) = TimelineStatusEntity(
|
||||
serverId = id,
|
||||
url = actionableStatus.url,
|
||||
tuskyAccountId = tuskyAccountId,
|
||||
authorServerId = actionableStatus.account.id,
|
||||
inReplyToId = actionableStatus.inReplyToId,
|
||||
inReplyToAccountId = actionableStatus.inReplyToAccountId,
|
||||
content = actionableStatus.content,
|
||||
createdAt = actionableStatus.createdAt.time,
|
||||
editedAt = actionableStatus.editedAt?.time,
|
||||
emojis = actionableStatus.emojis,
|
||||
reblogsCount = actionableStatus.reblogsCount,
|
||||
favouritesCount = actionableStatus.favouritesCount,
|
||||
reblogged = actionableStatus.reblogged,
|
||||
favourited = actionableStatus.favourited,
|
||||
bookmarked = actionableStatus.bookmarked,
|
||||
sensitive = actionableStatus.sensitive,
|
||||
spoilerText = actionableStatus.spoilerText,
|
||||
visibility = actionableStatus.visibility,
|
||||
attachments = actionableStatus.attachments,
|
||||
mentions = actionableStatus.mentions,
|
||||
tags = actionableStatus.tags,
|
||||
application = actionableStatus.application,
|
||||
poll = actionableStatus.poll,
|
||||
muted = actionableStatus.muted,
|
||||
expanded = expanded,
|
||||
contentShowing = contentShowing,
|
||||
contentCollapsed = contentCollapsed,
|
||||
pinned = actionableStatus.pinned,
|
||||
card = actionableStatus.card,
|
||||
repliesCount = actionableStatus.repliesCount,
|
||||
language = actionableStatus.language,
|
||||
filtered = actionableStatus.filtered
|
||||
)
|
||||
|
||||
fun TimelineStatusWithAccount.toViewData(moshi: Moshi, isDetailed: Boolean = false, translation: TranslationViewData? = null): StatusViewData {
|
||||
if (this.account == null) {
|
||||
Log.d(TAG, "Constructing Placeholder(${this.status.serverId}, ${this.status.expanded})")
|
||||
return StatusViewData.Placeholder(this.status.serverId, this.status.expanded)
|
||||
fun TimelineStatusEntity.toStatus(
|
||||
account: TimelineAccountEntity
|
||||
) = Status(
|
||||
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 mentions: List<Status.Mention> = status.mentions?.let { moshi.adapter<List<Status.Mention>?>().fromJson(it) }.orEmpty()
|
||||
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 ->
|
||||
val originalStatus = status.toStatus(account)
|
||||
val status = if (reblogAccount != null) {
|
||||
Status(
|
||||
id = id,
|
||||
url = status.url,
|
||||
account = account.toAccount(moshi),
|
||||
// no url for reblogs
|
||||
url = null,
|
||||
account = reblogAccount.toAccount(),
|
||||
inReplyToId = status.inReplyToId,
|
||||
inReplyToAccountId = status.inReplyToAccountId,
|
||||
reblog = null,
|
||||
content = status.content.orEmpty(),
|
||||
reblog = originalStatus,
|
||||
content = status.content,
|
||||
// lie but whatever?
|
||||
createdAt = Date(status.createdAt),
|
||||
editedAt = status.editedAt?.let { Date(it) },
|
||||
emojis = emojis,
|
||||
editedAt = null,
|
||||
emojis = emptyList(),
|
||||
reblogsCount = status.reblogsCount,
|
||||
favouritesCount = status.favouritesCount,
|
||||
reblogged = status.reblogged,
|
||||
|
@ -187,86 +172,22 @@ fun TimelineStatusWithAccount.toViewData(moshi: Moshi, isDetailed: Boolean = fal
|
|||
sensitive = status.sensitive,
|
||||
spoilerText = status.spoilerText,
|
||||
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(),
|
||||
mentions = emptyList(),
|
||||
tags = emptyList(),
|
||||
application = null,
|
||||
pinned = status.pinned,
|
||||
muted = status.muted ?: false,
|
||||
pinned = false,
|
||||
muted = status.muted,
|
||||
poll = null,
|
||||
card = null,
|
||||
repliesCount = status.repliesCount,
|
||||
language = status.language,
|
||||
filtered = status.filtered.orEmpty()
|
||||
filtered = status.filtered,
|
||||
)
|
||||
} else {
|
||||
Status(
|
||||
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()
|
||||
)
|
||||
originalStatus
|
||||
}
|
||||
|
||||
return StatusViewData.Concrete(
|
||||
status = status,
|
||||
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.db.AccountManager
|
||||
import com.keylesspalace.tusky.db.AppDatabase
|
||||
import com.keylesspalace.tusky.db.TimelineStatusEntity
|
||||
import com.keylesspalace.tusky.db.TimelineStatusWithAccount
|
||||
import com.keylesspalace.tusky.db.entity.HomeTimelineData
|
||||
import com.keylesspalace.tusky.db.entity.HomeTimelineEntity
|
||||
import com.keylesspalace.tusky.db.entity.TimelineStatusEntity
|
||||
import com.keylesspalace.tusky.entity.Status
|
||||
import com.keylesspalace.tusky.network.MastodonApi
|
||||
import com.squareup.moshi.Moshi
|
||||
import retrofit2.HttpException
|
||||
|
||||
@OptIn(ExperimentalPagingApi::class)
|
||||
|
@ -38,17 +38,18 @@ class CachedTimelineRemoteMediator(
|
|||
accountManager: AccountManager,
|
||||
private val api: MastodonApi,
|
||||
private val db: AppDatabase,
|
||||
private val moshi: Moshi
|
||||
) : RemoteMediator<Int, TimelineStatusWithAccount>() {
|
||||
) : RemoteMediator<Int, HomeTimelineData>() {
|
||||
|
||||
private var initialRefresh = false
|
||||
|
||||
private val timelineDao = db.timelineDao()
|
||||
private val statusDao = db.timelineStatusDao()
|
||||
private val accountDao = db.timelineAccountDao()
|
||||
private val activeAccount = accountManager.activeAccount!!
|
||||
|
||||
override suspend fun load(
|
||||
loadType: LoadType,
|
||||
state: PagingState<Int, TimelineStatusWithAccount>
|
||||
state: PagingState<Int, HomeTimelineData>
|
||||
): MediatorResult {
|
||||
if (!activeAccount.isLoggedIn()) {
|
||||
return MediatorResult.Success(endOfPaginationReached = true)
|
||||
|
@ -111,7 +112,7 @@ class CachedTimelineRemoteMediator(
|
|||
/* 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 */
|
||||
timelineDao.insertStatus(
|
||||
timelineDao.insertHomeTimelineItem(
|
||||
Placeholder(statuses.last().id, loading = false).toEntity(activeAccount.id)
|
||||
)
|
||||
}
|
||||
|
@ -134,7 +135,7 @@ class CachedTimelineRemoteMediator(
|
|||
*/
|
||||
private suspend fun replaceStatusRange(
|
||||
statuses: List<Status>,
|
||||
state: PagingState<Int, TimelineStatusWithAccount>
|
||||
state: PagingState<Int, HomeTimelineData>
|
||||
): Int {
|
||||
val overlappedStatuses = if (statuses.isNotEmpty()) {
|
||||
timelineDao.deleteRange(activeAccount.id, statuses.last().id, statuses.first().id)
|
||||
|
@ -143,9 +144,9 @@ class CachedTimelineRemoteMediator(
|
|||
}
|
||||
|
||||
for (status in statuses) {
|
||||
timelineDao.insertAccount(status.account.toEntity(activeAccount.id, moshi))
|
||||
status.reblog?.account?.toEntity(activeAccount.id, moshi)?.let { rebloggedAccount ->
|
||||
timelineDao.insertAccount(rebloggedAccount)
|
||||
accountDao.insert(status.account.toEntity(activeAccount.id))
|
||||
status.reblog?.account?.toEntity(activeAccount.id)?.let { rebloggedAccount ->
|
||||
accountDao.insert(rebloggedAccount)
|
||||
}
|
||||
|
||||
// check if we already have one of the newly loaded statuses cached locally
|
||||
|
@ -153,31 +154,35 @@ class CachedTimelineRemoteMediator(
|
|||
var oldStatus: TimelineStatusEntity? = null
|
||||
for (page in state.pages) {
|
||||
oldStatus = page.data.find { s ->
|
||||
s.status.serverId == status.id
|
||||
s.status?.serverId == status.actionableId
|
||||
}?.status
|
||||
if (oldStatus != null) break
|
||||
}
|
||||
|
||||
// The "expanded" property for Placeholders determines whether or not they are
|
||||
// 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 expanded = oldStatus?.expanded ?: activeAccount.alwaysOpenSpoiler
|
||||
val contentShowing = oldStatus?.contentShowing ?: (activeAccount.alwaysShowSensitiveMedia || !status.actionableStatus.sensitive)
|
||||
val contentCollapsed = oldStatus?.contentCollapsed ?: true
|
||||
|
||||
timelineDao.insertStatus(
|
||||
status.toEntity(
|
||||
timelineUserId = activeAccount.id,
|
||||
moshi = moshi,
|
||||
statusDao.insert(
|
||||
status.actionableStatus.toEntity(
|
||||
tuskyAccountId = activeAccount.id,
|
||||
expanded = expanded,
|
||||
contentShowing = contentShowing,
|
||||
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
|
||||
}
|
||||
|
|
|
@ -38,20 +38,18 @@ import com.keylesspalace.tusky.components.timeline.toViewData
|
|||
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.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.Poll
|
||||
import com.keylesspalace.tusky.entity.Status
|
||||
import com.keylesspalace.tusky.network.FilterModel
|
||||
import com.keylesspalace.tusky.network.MastodonApi
|
||||
import com.keylesspalace.tusky.usecase.TimelineCases
|
||||
import com.keylesspalace.tusky.util.EmptyPagingSource
|
||||
import com.keylesspalace.tusky.viewdata.StatusViewData
|
||||
import com.keylesspalace.tusky.viewdata.TranslationViewData
|
||||
import com.squareup.moshi.Moshi
|
||||
import javax.inject.Inject
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.asExecutor
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
|
@ -68,8 +66,7 @@ class CachedTimelineViewModel @Inject constructor(
|
|||
accountManager: AccountManager,
|
||||
sharedPreferences: SharedPreferences,
|
||||
filterModel: FilterModel,
|
||||
private val db: AppDatabase,
|
||||
private val moshi: Moshi
|
||||
private val db: AppDatabase
|
||||
) : TimelineViewModel(
|
||||
timelineCases,
|
||||
api,
|
||||
|
@ -79,7 +76,7 @@ class CachedTimelineViewModel @Inject constructor(
|
|||
filterModel
|
||||
) {
|
||||
|
||||
private var currentPagingSource: PagingSource<Int, TimelineStatusWithAccount>? = null
|
||||
private var currentPagingSource: PagingSource<Int, HomeTimelineData>? = null
|
||||
|
||||
/** Map from status id to translation. */
|
||||
private val translations = MutableStateFlow(mapOf<String, TranslationViewData>())
|
||||
|
@ -87,13 +84,13 @@ class CachedTimelineViewModel @Inject constructor(
|
|||
@OptIn(ExperimentalPagingApi::class)
|
||||
override val statuses = Pager(
|
||||
config = PagingConfig(pageSize = LOAD_AT_ONCE),
|
||||
remoteMediator = CachedTimelineRemoteMediator(accountManager, api, db, moshi),
|
||||
remoteMediator = CachedTimelineRemoteMediator(accountManager, api, db),
|
||||
pagingSourceFactory = {
|
||||
val activeAccount = accountManager.activeAccount
|
||||
if (activeAccount == null) {
|
||||
EmptyPagingSource()
|
||||
} else {
|
||||
db.timelineDao().getStatuses(activeAccount.id)
|
||||
db.timelineDao().getHomeTimeline(activeAccount.id)
|
||||
}.also { newPagingSource ->
|
||||
this.currentPagingSource = newPagingSource
|
||||
}
|
||||
|
@ -105,14 +102,13 @@ class CachedTimelineViewModel @Inject constructor(
|
|||
// adding another cachedIn() for the overall result.
|
||||
.cachedIn(viewModelScope)
|
||||
.combine(translations) { pagingData, translations ->
|
||||
pagingData.map(Dispatchers.Default.asExecutor()) { timelineStatus ->
|
||||
val translation = translations[timelineStatus.status.serverId]
|
||||
timelineStatus.toViewData(
|
||||
moshi,
|
||||
pagingData.map { timelineData ->
|
||||
val translation = translations[timelineData.status?.serverId]
|
||||
timelineData.toViewData(
|
||||
isDetailed = false,
|
||||
translation = translation
|
||||
)
|
||||
}.filter(Dispatchers.Default.asExecutor()) { statusViewData ->
|
||||
}.filter { statusViewData ->
|
||||
shouldFilterStatus(statusViewData) != Filter.Action.HIDE
|
||||
}
|
||||
}
|
||||
|
@ -124,39 +120,28 @@ class CachedTimelineViewModel @Inject constructor(
|
|||
|
||||
override fun changeExpanded(expanded: Boolean, status: StatusViewData.Concrete) {
|
||||
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) {
|
||||
viewModelScope.launch {
|
||||
db.timelineDao()
|
||||
.setContentShowing(accountManager.activeAccount!!.id, status.id, isShowing)
|
||||
db.timelineStatusDao()
|
||||
.setContentShowing(accountManager.activeAccount!!.id, status.actionableId, isShowing)
|
||||
}
|
||||
}
|
||||
|
||||
override fun changeContentCollapsed(isCollapsed: Boolean, status: StatusViewData.Concrete) {
|
||||
viewModelScope.launch {
|
||||
db.timelineDao()
|
||||
.setContentCollapsed(accountManager.activeAccount!!.id, status.id, 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)
|
||||
db.timelineStatusDao()
|
||||
.setContentCollapsed(accountManager.activeAccount!!.id, status.actionableId, isCollapsed)
|
||||
}
|
||||
}
|
||||
|
||||
override fun clearWarning(status: StatusViewData.Concrete) {
|
||||
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 {
|
||||
try {
|
||||
val timelineDao = db.timelineDao()
|
||||
val statusDao = db.timelineStatusDao()
|
||||
val accountDao = db.timelineAccountDao()
|
||||
|
||||
val activeAccount = accountManager.activeAccount!!
|
||||
|
||||
timelineDao.insertStatus(
|
||||
timelineDao.insertHomeTimelineItem(
|
||||
Placeholder(placeholderId, loading = true).toEntity(
|
||||
activeAccount.id
|
||||
)
|
||||
|
@ -205,7 +192,7 @@ class CachedTimelineViewModel @Inject constructor(
|
|||
}
|
||||
|
||||
db.withTransaction {
|
||||
timelineDao.delete(activeAccount.id, placeholderId)
|
||||
timelineDao.deleteHomeTimelineItem(activeAccount.id, placeholderId)
|
||||
|
||||
val overlappedStatuses = if (statuses.isNotEmpty()) {
|
||||
timelineDao.deleteRange(
|
||||
|
@ -218,20 +205,31 @@ class CachedTimelineViewModel @Inject constructor(
|
|||
}
|
||||
|
||||
for (status in statuses) {
|
||||
timelineDao.insertAccount(status.account.toEntity(activeAccount.id, moshi))
|
||||
status.reblog?.account?.toEntity(activeAccount.id, moshi)
|
||||
accountDao.insert(status.account.toEntity(activeAccount.id))
|
||||
status.reblog?.account?.toEntity(activeAccount.id)
|
||||
?.let { rebloggedAccount ->
|
||||
timelineDao.insertAccount(rebloggedAccount)
|
||||
accountDao.insert(rebloggedAccount)
|
||||
}
|
||||
timelineDao.insertStatus(
|
||||
status.toEntity(
|
||||
timelineUserId = activeAccount.id,
|
||||
moshi = moshi,
|
||||
statusDao.insert(
|
||||
status.actionableStatus.toEntity(
|
||||
tuskyAccountId = activeAccount.id,
|
||||
expanded = activeAccount.alwaysOpenSpoiler,
|
||||
contentShowing = activeAccount.alwaysShowSensitiveMedia || !status.actionableStatus.sensitive,
|
||||
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,
|
||||
|
@ -244,7 +242,7 @@ class CachedTimelineViewModel @Inject constructor(
|
|||
OLDEST_FIRST -> statuses.first().id
|
||||
NEWEST_FIRST -> statuses.last().id
|
||||
}
|
||||
timelineDao.insertStatus(
|
||||
timelineDao.insertHomeTimelineItem(
|
||||
Placeholder(
|
||||
idToConvert,
|
||||
loading = false
|
||||
|
@ -264,17 +262,13 @@ class CachedTimelineViewModel @Inject constructor(
|
|||
Log.w("CachedTimelineVM", "failed loading statuses", e)
|
||||
val activeAccount = accountManager.activeAccount!!
|
||||
db.timelineDao()
|
||||
.insertStatus(Placeholder(placeholderId, loading = false).toEntity(activeAccount.id))
|
||||
}
|
||||
|
||||
override fun handleStatusChangedEvent(status: Status) {
|
||||
// handled by CacheUpdater
|
||||
.insertHomeTimelineItem(Placeholder(placeholderId, loading = false).toEntity(activeAccount.id))
|
||||
}
|
||||
|
||||
override fun fullReload() {
|
||||
viewModelScope.launch {
|
||||
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() {
|
||||
// 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()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -26,7 +26,14 @@ import androidx.paging.filter
|
|||
import at.connyduck.calladapter.networkresult.NetworkResult
|
||||
import at.connyduck.calladapter.networkresult.map
|
||||
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.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.db.AccountManager
|
||||
import com.keylesspalace.tusky.entity.Filter
|
||||
|
@ -96,6 +103,48 @@ class NetworkTimelineViewModel @Inject constructor(
|
|||
.flowOn(Dispatchers.Default)
|
||||
.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) {
|
||||
status.copy(
|
||||
status = status.status.copy(poll = newPoll)
|
||||
|
@ -120,7 +169,7 @@ class NetworkTimelineViewModel @Inject constructor(
|
|||
).update()
|
||||
}
|
||||
|
||||
override fun removeAllByAccountId(accountId: String) {
|
||||
private fun removeAllByAccountId(accountId: String) {
|
||||
statusData.removeAll { vd ->
|
||||
val status = vd.asStatusOrNull()?.status ?: return@removeAll false
|
||||
status.account.id == accountId || status.actionableStatus.account.id == accountId
|
||||
|
@ -128,7 +177,7 @@ class NetworkTimelineViewModel @Inject constructor(
|
|||
currentSource?.invalidate()
|
||||
}
|
||||
|
||||
override fun removeAllByInstance(instance: String) {
|
||||
private fun removeAllByInstance(instance: String) {
|
||||
statusData.removeAll { vd ->
|
||||
val status = vd.asStatusOrNull()?.status ?: return@removeAll false
|
||||
getDomain(status.account.url) == instance
|
||||
|
@ -241,7 +290,7 @@ class NetworkTimelineViewModel @Inject constructor(
|
|||
currentSource?.invalidate()
|
||||
}
|
||||
|
||||
override fun handleStatusChangedEvent(status: Status) {
|
||||
private fun handleStatusChangedEvent(status: Status) {
|
||||
updateStatusById(status.id) { oldViewData ->
|
||||
status.toViewData(
|
||||
isShowingContent = oldViewData.isShowingContent,
|
||||
|
|
|
@ -24,24 +24,17 @@ import at.connyduck.calladapter.networkresult.NetworkResult
|
|||
import at.connyduck.calladapter.networkresult.fold
|
||||
import at.connyduck.calladapter.networkresult.getOrElse
|
||||
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.EventHub
|
||||
import com.keylesspalace.tusky.appstore.FilterUpdatedEvent
|
||||
import com.keylesspalace.tusky.appstore.MuteConversationEvent
|
||||
import com.keylesspalace.tusky.appstore.MuteEvent
|
||||
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.timeline.util.ifExpected
|
||||
import com.keylesspalace.tusky.db.AccountManager
|
||||
import com.keylesspalace.tusky.entity.Filter
|
||||
import com.keylesspalace.tusky.entity.FilterV1
|
||||
import com.keylesspalace.tusky.entity.Poll
|
||||
import com.keylesspalace.tusky.entity.Status
|
||||
import com.keylesspalace.tusky.network.FilterModel
|
||||
import com.keylesspalace.tusky.network.MastodonApi
|
||||
import com.keylesspalace.tusky.settings.PrefKeys
|
||||
|
@ -162,16 +155,10 @@ abstract class TimelineViewModel(
|
|||
|
||||
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 loadMore(placeholderId: String)
|
||||
|
||||
abstract fun handleStatusChangedEvent(status: Status)
|
||||
|
||||
abstract fun fullReload()
|
||||
|
||||
abstract fun clearWarning(status: StatusViewData.Concrete)
|
||||
|
@ -240,37 +227,7 @@ abstract class TimelineViewModel(
|
|||
|
||||
private fun handleEvent(event: Event) {
|
||||
when (event) {
|
||||
is StatusChangedEvent -> handleStatusChangedEvent(event.status)
|
||||
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 -> {
|
||||
onPreferenceChanged(event.preferenceKey)
|
||||
}
|
||||
|
|
|
@ -24,12 +24,13 @@ import at.connyduck.calladapter.networkresult.getOrElse
|
|||
import at.connyduck.calladapter.networkresult.getOrThrow
|
||||
import at.connyduck.calladapter.networkresult.map
|
||||
import at.connyduck.calladapter.networkresult.onFailure
|
||||
import at.connyduck.calladapter.networkresult.onSuccess
|
||||
import com.keylesspalace.tusky.appstore.BlockEvent
|
||||
import com.keylesspalace.tusky.appstore.EventHub
|
||||
import com.keylesspalace.tusky.appstore.StatusChangedEvent
|
||||
import com.keylesspalace.tusky.appstore.StatusComposedEvent
|
||||
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.db.AccountManager
|
||||
import com.keylesspalace.tusky.db.AppDatabase
|
||||
|
@ -108,24 +109,18 @@ class ViewThreadViewModel @Inject constructor(
|
|||
viewModelScope.launch {
|
||||
Log.d(TAG, "Finding status with: $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")
|
||||
val viewData = timelineStatus.toViewData(
|
||||
moshi,
|
||||
StatusViewData.Concrete(
|
||||
status = statusAndAccount.first.toStatus(statusAndAccount.second),
|
||||
isExpanded = statusAndAccount.first.expanded,
|
||||
isShowingContent = statusAndAccount.first.contentShowing,
|
||||
isCollapsed = statusAndAccount.first.contentCollapsed,
|
||||
isDetailed = true,
|
||||
) as StatusViewData.Concrete
|
||||
|
||||
// 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
|
||||
}
|
||||
translation = null
|
||||
)
|
||||
} else {
|
||||
Log.d(TAG, "Loaded status from network")
|
||||
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
|
||||
// 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
|
||||
// failed.
|
||||
if (timelineStatus != null) {
|
||||
api.status(id).getOrNull()?.let { result ->
|
||||
db.timelineDao().update(
|
||||
accountId = accountManager.activeAccount!!.id,
|
||||
status = result
|
||||
// failed. Update the database when the fetch was successful.
|
||||
if (statusAndAccount != null) {
|
||||
api.status(id).onSuccess { result ->
|
||||
db.timelineStatusDao().update(
|
||||
tuskyAccountId = accountManager.activeAccount!!.id,
|
||||
status = result,
|
||||
moshi = moshi
|
||||
)
|
||||
detailedStatus = result.toViewData(isDetailed = true)
|
||||
}
|
||||
|
|
|
@ -18,6 +18,8 @@ package com.keylesspalace.tusky.db
|
|||
import android.content.Context
|
||||
import android.util.Log
|
||||
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.Status
|
||||
import com.keylesspalace.tusky.settings.PrefKeys
|
||||
|
|
|
@ -27,6 +27,21 @@ import androidx.sqlite.db.SupportSQLiteDatabase;
|
|||
|
||||
import com.keylesspalace.tusky.TabDataKt;
|
||||
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;
|
||||
|
||||
|
@ -40,11 +55,14 @@ import java.io.File;
|
|||
InstanceEntity.class,
|
||||
TimelineStatusEntity.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.
|
||||
// This is to reserve odd version numbers for use by forks.
|
||||
version = 58,
|
||||
version = 60,
|
||||
autoMigrations = {
|
||||
@AutoMigration(from = 48, to = 49),
|
||||
@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 TimelineDao timelineDao();
|
||||
@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) {
|
||||
@Override
|
||||
|
@ -698,4 +719,126 @@ public abstract class AppDatabase extends RoomDatabase {
|
|||
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.components.conversation.ConversationAccountEntity
|
||||
import com.keylesspalace.tusky.createTabDataFromId
|
||||
import com.keylesspalace.tusky.db.entity.DraftAttachment
|
||||
import com.keylesspalace.tusky.entity.Attachment
|
||||
import com.keylesspalace.tusky.entity.Card
|
||||
import com.keylesspalace.tusky.entity.Emoji
|
||||
|
@ -187,4 +188,29 @@ class Converters @Inject constructor(
|
|||
fun cardToJson(card: Card?): String {
|
||||
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 com.keylesspalace.tusky.R
|
||||
import com.keylesspalace.tusky.components.drafts.DraftsActivity
|
||||
import com.keylesspalace.tusky.db.dao.DraftDao
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
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,
|
||||
* see <http://www.gnu.org/licenses>. */
|
||||
|
||||
package com.keylesspalace.tusky.db
|
||||
package com.keylesspalace.tusky.db.dao
|
||||
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Delete
|
||||
import androidx.room.Insert
|
||||
import androidx.room.OnConflictStrategy
|
||||
import androidx.room.Query
|
||||
import com.keylesspalace.tusky.db.entity.AccountEntity
|
||||
|
||||
@Dao
|
||||
interface AccountDao {
|
|
@ -13,13 +13,14 @@
|
|||
* 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
|
||||
package com.keylesspalace.tusky.db.dao
|
||||
|
||||
import androidx.paging.PagingSource
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Insert
|
||||
import androidx.room.OnConflictStrategy
|
||||
import androidx.room.Query
|
||||
import com.keylesspalace.tusky.db.entity.DraftEntity
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
@Dao
|
|
@ -13,12 +13,15 @@
|
|||
* 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
|
||||
package com.keylesspalace.tusky.db.dao
|
||||
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Query
|
||||
import androidx.room.RewriteQueriesToDropUnusedColumns
|
||||
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
|
||||
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,
|
||||
* see <http://www.gnu.org/licenses>. */
|
||||
|
||||
package com.keylesspalace.tusky.db
|
||||
package com.keylesspalace.tusky.db.entity
|
||||
|
||||
import androidx.room.ColumnInfo
|
||||
import androidx.room.Entity
|
||||
|
@ -21,6 +21,7 @@ import androidx.room.Index
|
|||
import androidx.room.PrimaryKey
|
||||
import androidx.room.TypeConverters
|
||||
import com.keylesspalace.tusky.TabData
|
||||
import com.keylesspalace.tusky.db.Converters
|
||||
import com.keylesspalace.tusky.defaultTabs
|
||||
import com.keylesspalace.tusky.entity.Emoji
|
||||
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,
|
||||
* see <http://www.gnu.org/licenses>. */
|
||||
|
||||
package com.keylesspalace.tusky.db
|
||||
package com.keylesspalace.tusky.db.entity
|
||||
|
||||
import android.net.Uri
|
||||
import android.os.Parcelable
|
||||
|
@ -21,6 +21,7 @@ import androidx.core.net.toUri
|
|||
import androidx.room.Entity
|
||||
import androidx.room.PrimaryKey
|
||||
import androidx.room.TypeConverters
|
||||
import com.keylesspalace.tusky.db.Converters
|
||||
import com.keylesspalace.tusky.entity.Attachment
|
||||
import com.keylesspalace.tusky.entity.NewPoll
|
||||
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,
|
||||
* see <http://www.gnu.org/licenses>. */
|
||||
|
||||
package com.keylesspalace.tusky.db
|
||||
package com.keylesspalace.tusky.db.entity
|
||||
|
||||
import androidx.room.Entity
|
||||
import androidx.room.PrimaryKey
|
||||
import androidx.room.TypeConverters
|
||||
import com.keylesspalace.tusky.db.Converters
|
||||
import com.keylesspalace.tusky.entity.Emoji
|
||||
|
||||
@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,
|
||||
* 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.ForeignKey
|
||||
import androidx.room.Index
|
||||
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.HashTag
|
||||
import com.keylesspalace.tusky.entity.Poll
|
||||
import com.keylesspalace.tusky.entity.Status
|
||||
|
||||
/**
|
||||
* We're trying to play smart here. Server sends us reblogs as two entities one embedded into
|
||||
* another (reblogged status is a field inside of "reblog" status). But it's really inefficient from
|
||||
* 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 for caching status data. Used within home timelines and notifications.
|
||||
* The information if a status is a reblog is not stored here but in [HomeTimelineEntity].
|
||||
*/
|
||||
@Entity(
|
||||
primaryKeys = ["serverId", "timelineUserId"],
|
||||
primaryKeys = ["serverId", "tuskyAccountId"],
|
||||
foreignKeys = (
|
||||
[
|
||||
ForeignKey(
|
||||
entity = TimelineAccountEntity::class,
|
||||
parentColumns = ["serverId", "timelineUserId"],
|
||||
childColumns = ["authorServerId", "timelineUserId"]
|
||||
parentColumns = ["serverId", "tuskyAccountId"],
|
||||
childColumns = ["authorServerId", "tuskyAccountId"]
|
||||
)
|
||||
]
|
||||
),
|
||||
// Avoiding rescanning status table when accounts table changes. Recommended by Room(c).
|
||||
indices = [Index("authorServerId", "timelineUserId")]
|
||||
indices = [Index("authorServerId", "tuskyAccountId")]
|
||||
)
|
||||
@TypeConverters(Converters::class)
|
||||
data class TimelineStatusEntity(
|
||||
|
@ -54,14 +52,14 @@ data class TimelineStatusEntity(
|
|||
val serverId: String,
|
||||
val url: String?,
|
||||
// our local id for the logged in user in case there are multiple accounts per instance
|
||||
val timelineUserId: Long,
|
||||
val authorServerId: String?,
|
||||
val tuskyAccountId: Long,
|
||||
val authorServerId: String,
|
||||
val inReplyToId: String?,
|
||||
val inReplyToAccountId: String?,
|
||||
val content: String?,
|
||||
val content: String,
|
||||
val createdAt: Long,
|
||||
val editedAt: Long?,
|
||||
val emojis: String?,
|
||||
val emojis: List<Emoji>,
|
||||
val reblogsCount: Int,
|
||||
val favouritesCount: Int,
|
||||
val repliesCount: Int,
|
||||
|
@ -71,50 +69,19 @@ data class TimelineStatusEntity(
|
|||
val sensitive: Boolean,
|
||||
val spoilerText: String,
|
||||
val visibility: Status.Visibility,
|
||||
val attachments: String?,
|
||||
val mentions: String?,
|
||||
val tags: String?,
|
||||
val application: String?,
|
||||
val attachments: List<Attachment>,
|
||||
val mentions: List<Status.Mention>,
|
||||
val tags: List<HashTag>,
|
||||
val application: Status.Application?,
|
||||
// if it has a reblogged status, it's id is stored here
|
||||
val reblogServerId: String?,
|
||||
val reblogAccountId: String?,
|
||||
val poll: String?,
|
||||
val muted: Boolean?,
|
||||
val poll: Poll?,
|
||||
val muted: Boolean,
|
||||
/** Also used as the "loading" attribute when this TimelineStatusEntity is a placeholder */
|
||||
val expanded: Boolean,
|
||||
val contentCollapsed: Boolean,
|
||||
val contentShowing: Boolean,
|
||||
val pinned: Boolean,
|
||||
val card: String?,
|
||||
val card: Card?,
|
||||
val language: String?,
|
||||
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
|
||||
val filtered: List<FilterResult>
|
||||
)
|
|
@ -68,7 +68,8 @@ class AppModule {
|
|||
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_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()
|
||||
}
|
||||
|
|
|
@ -21,6 +21,7 @@ import com.keylesspalace.tusky.components.account.media.AccountMediaFragment
|
|||
import com.keylesspalace.tusky.components.accountlist.AccountListFragment
|
||||
import com.keylesspalace.tusky.components.conversation.ConversationsFragment
|
||||
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.NotificationPreferencesFragment
|
||||
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.viewthread.ViewThreadFragment
|
||||
import com.keylesspalace.tusky.components.viewthread.edits.ViewEditsFragment
|
||||
import com.keylesspalace.tusky.fragment.NotificationsFragment
|
||||
import com.keylesspalace.tusky.fragment.ViewVideoFragment
|
||||
import dagger.Module
|
||||
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.followedtags.FollowedTagsViewModel
|
||||
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.scheduled.ScheduledStatusViewModel
|
||||
import com.keylesspalace.tusky.components.search.SearchViewModel
|
||||
|
@ -192,5 +193,10 @@ abstract class ViewModelModule {
|
|||
@ViewModelKey(DomainBlocksViewModel::class)
|
||||
internal abstract fun instanceMuteViewModel(viewModel: DomainBlocksViewModel): ViewModel
|
||||
|
||||
@Binds
|
||||
@IntoMap
|
||||
@ViewModelKey(NotificationsViewModel::class)
|
||||
internal abstract fun notificationsViewModel(viewModel: NotificationsViewModel): ViewModel
|
||||
|
||||
// Add more ViewModels here
|
||||
}
|
||||
|
|
|
@ -75,7 +75,6 @@ data class Notification(
|
|||
REPORT("admin.report", R.string.notification_report_name);
|
||||
|
||||
companion object {
|
||||
@JvmStatic
|
||||
fun byString(s: String): Type {
|
||||
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)
|
||||
}
|
||||
|
||||
override fun toString(): String {
|
||||
return presentation
|
||||
}
|
||||
override fun toString() = 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
|
||||
fun rewriteToStatusTypeIfNeeded(accountId: String): Notification {
|
||||
if (type == Type.MENTION && status != null) {
|
||||
|
|
|
@ -47,7 +47,7 @@ data class Status(
|
|||
@Json(name = "media_attachments") val attachments: List<Attachment>,
|
||||
val mentions: List<Mention>,
|
||||
// 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 pinned: Boolean = false,
|
||||
val muted: Boolean = false,
|
||||
|
@ -66,13 +66,6 @@ data class Status(
|
|||
val actionableStatus: Status
|
||||
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)
|
||||
enum class Visibility(val num: Int) {
|
||||
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.instanceinfo.InstanceInfoRepository
|
||||
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.entity.AccountEntity
|
||||
import com.keylesspalace.tusky.di.Injectable
|
||||
import com.keylesspalace.tusky.entity.Attachment
|
||||
import com.keylesspalace.tusky.entity.Status
|
||||
|
|
|
@ -15,7 +15,7 @@
|
|||
|
||||
package com.keylesspalace.tusky.interfaces
|
||||
|
||||
import com.keylesspalace.tusky.db.AccountEntity
|
||||
import com.keylesspalace.tusky.db.entity.AccountEntity
|
||||
|
||||
interface AccountSelectionListener {
|
||||
fun onAccountSelected(account: AccountEntity)
|
||||
|
|
|
@ -137,15 +137,18 @@ interface MastodonApi {
|
|||
): Response<List<Status>>
|
||||
|
||||
@GET("api/v1/notifications")
|
||||
@Throws(Exception::class)
|
||||
suspend fun notifications(
|
||||
/** Return results older than this ID */
|
||||
@Query("max_id") maxId: String?,
|
||||
@Query("max_id") maxId: String? = null,
|
||||
/** 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 */
|
||||
@Query("limit") limit: Int?,
|
||||
@Query("limit") limit: Int? = null,
|
||||
/** Types to excludes from the results */
|
||||
@Query("exclude_types[]") excludes: Set<Notification.Type>?
|
||||
@Query("exclude_types[]") excludes: Set<Notification.Type>? = null
|
||||
): Response<List<Notification>>
|
||||
|
||||
/** Fetch a single notification */
|
||||
|
|
|
@ -20,9 +20,9 @@ import android.content.BroadcastReceiver
|
|||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import com.keylesspalace.tusky.components.notifications.canEnablePushNotifications
|
||||
import com.keylesspalace.tusky.components.notifications.isUnifiedPushNotificationEnabledForAccount
|
||||
import com.keylesspalace.tusky.components.notifications.updateUnifiedPushSubscription
|
||||
import com.keylesspalace.tusky.components.systemnotifications.canEnablePushNotifications
|
||||
import com.keylesspalace.tusky.components.systemnotifications.isUnifiedPushNotificationEnabledForAccount
|
||||
import com.keylesspalace.tusky.components.systemnotifications.updateUnifiedPushSubscription
|
||||
import com.keylesspalace.tusky.db.AccountManager
|
||||
import com.keylesspalace.tusky.di.ApplicationScope
|
||||
import com.keylesspalace.tusky.network.MastodonApi
|
||||
|
|
|
@ -24,7 +24,7 @@ import androidx.core.app.NotificationCompat
|
|||
import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.core.app.RemoteInput
|
||||
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.entity.Status
|
||||
import com.keylesspalace.tusky.service.SendStatusService
|
||||
|
|
|
@ -20,8 +20,8 @@ import android.content.Intent
|
|||
import android.util.Log
|
||||
import androidx.work.OneTimeWorkRequest
|
||||
import androidx.work.WorkManager
|
||||
import com.keylesspalace.tusky.components.notifications.registerUnifiedPushEndpoint
|
||||
import com.keylesspalace.tusky.components.notifications.unregisterUnifiedPushEndpoint
|
||||
import com.keylesspalace.tusky.components.systemnotifications.registerUnifiedPushEndpoint
|
||||
import com.keylesspalace.tusky.components.systemnotifications.unregisterUnifiedPushEndpoint
|
||||
import com.keylesspalace.tusky.db.AccountManager
|
||||
import com.keylesspalace.tusky.di.ApplicationScope
|
||||
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.UploadEvent
|
||||
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.di.Injectable
|
||||
import com.keylesspalace.tusky.entity.Attachment
|
||||
|
|
|
@ -3,8 +3,8 @@ package com.keylesspalace.tusky.settings
|
|||
import androidx.preference.PreferenceDataStore
|
||||
import com.keylesspalace.tusky.appstore.EventHub
|
||||
import com.keylesspalace.tusky.appstore.PreferenceChangedEvent
|
||||
import com.keylesspalace.tusky.db.AccountEntity
|
||||
import com.keylesspalace.tusky.db.AccountManager
|
||||
import com.keylesspalace.tusky.db.entity.AccountEntity
|
||||
import com.keylesspalace.tusky.di.ApplicationScope
|
||||
import javax.inject.Inject
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
|
|
|
@ -62,13 +62,13 @@ object PrefKeys {
|
|||
const val READING_ORDER = "readingOrder"
|
||||
const val MAIN_NAV_POSITION = "mainNavPosition"
|
||||
const val HIDE_TOP_TOOLBAR = "hideTopToolbar"
|
||||
const val SHOW_NOTIFICATIONS_FILTER = "showNotificationsFilter"
|
||||
const val ABSOLUTE_TIME_VIEW = "absoluteTimeView"
|
||||
const val SHOW_BOT_OVERLAY = "showBotOverlay"
|
||||
const val ANIMATE_GIF_AVATARS = "animateGifAvatars"
|
||||
const val USE_BLURHASH = "useBlurhash"
|
||||
const val SHOW_SELF_USERNAME = "showSelfUsername"
|
||||
const val SHOW_CARDS_IN_TIMELINES = "showCardsInTimelines"
|
||||
const val SHOW_NOTIFICATIONS_FILTER = "showNotificationsFilter"
|
||||
const val CONFIRM_REBLOGS = "confirmReblogs"
|
||||
const val CONFIRM_FAVOURITES = "confirmFavourites"
|
||||
const val ENABLE_SWIPE_FOR_TABS = "enableSwipeForTabs"
|
||||
|
@ -111,9 +111,4 @@ object PrefKeys {
|
|||
|
||||
/** UI text scaling factor, stored as float, 100 = 100% = no scaling */
|
||||
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 androidx.room.withTransaction
|
||||
import com.keylesspalace.tusky.db.AppDatabase
|
||||
import com.keylesspalace.tusky.db.TimelineDao
|
||||
import com.keylesspalace.tusky.db.dao.TimelineDao
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
|
@ -25,18 +25,18 @@ class DeveloperToolsUseCase @Inject constructor(
|
|||
*/
|
||||
suspend fun createLoadMoreGap(accountId: Long) {
|
||||
db.withTransaction {
|
||||
val ids = timelineDao.getMostRecentNStatusIds(accountId, 10)
|
||||
val ids = timelineDao.getMostRecentNHomeTimelineIds(accountId, 10)
|
||||
val maxId = ids[2]
|
||||
val minId = ids[8]
|
||||
val placeHolderId = ids[9]
|
||||
|
||||
Log.d(
|
||||
"TAG",
|
||||
TAG,
|
||||
"createLoadMoreGap: creating gap between $minId .. $maxId (new placeholder: $placeHolderId"
|
||||
)
|
||||
|
||||
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 com.keylesspalace.tusky.components.drafts.DraftHelper
|
||||
import com.keylesspalace.tusky.components.notifications.NotificationHelper
|
||||
import com.keylesspalace.tusky.components.notifications.disableUnifiedPushNotificationsForAccount
|
||||
import com.keylesspalace.tusky.components.systemnotifications.NotificationHelper
|
||||
import com.keylesspalace.tusky.components.systemnotifications.disableUnifiedPushNotificationsForAccount
|
||||
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.util.ShareShortcutHelper
|
||||
import javax.inject.Inject
|
||||
|
@ -13,7 +13,7 @@ import javax.inject.Inject
|
|||
class LogoutUsecase @Inject constructor(
|
||||
private val context: Context,
|
||||
private val api: MastodonApi,
|
||||
private val db: AppDatabase,
|
||||
private val databaseCleaner: DatabaseCleaner,
|
||||
private val accountManager: AccountManager,
|
||||
private val draftHelper: DraftHelper,
|
||||
private val shareShortcutHelper: ShareShortcutHelper
|
||||
|
@ -53,8 +53,7 @@ class LogoutUsecase @Inject constructor(
|
|||
val otherAccountAvailable = accountManager.logActiveAccountOut() != null
|
||||
|
||||
// clear the database - this could trigger network calls so do it last when all tokens are gone
|
||||
db.timelineDao().removeAll(activeAccount.id)
|
||||
db.conversationDao().deleteForAccount(activeAccount.id)
|
||||
databaseCleaner.cleanupEverything(activeAccount.id)
|
||||
draftHelper.deleteAllDraftsAndAttachmentsForAccount(activeAccount.id)
|
||||
|
||||
// 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.onFailure
|
||||
import at.connyduck.calladapter.networkresult.onSuccess
|
||||
import at.connyduck.calladapter.networkresult.runCatching
|
||||
import com.keylesspalace.tusky.appstore.BlockEvent
|
||||
import com.keylesspalace.tusky.appstore.EventHub
|
||||
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.StatusDeletedEvent
|
||||
import com.keylesspalace.tusky.entity.DeletedStatus
|
||||
import com.keylesspalace.tusky.entity.Notification
|
||||
import com.keylesspalace.tusky.entity.Poll
|
||||
import com.keylesspalace.tusky.entity.Relationship
|
||||
import com.keylesspalace.tusky.entity.Status
|
||||
import com.keylesspalace.tusky.entity.Translation
|
||||
import com.keylesspalace.tusky.network.MastodonApi
|
||||
import com.keylesspalace.tusky.util.Single
|
||||
import com.keylesspalace.tusky.util.getServerErrorMessage
|
||||
import java.util.Locale
|
||||
import javax.inject.Inject
|
||||
import retrofit2.Response
|
||||
|
||||
/**
|
||||
* 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> {
|
||||
return if (favourite) {
|
||||
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> {
|
||||
return if (bookmark) {
|
||||
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> {
|
||||
return if (mute) {
|
||||
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(
|
||||
statusId: String
|
||||
): 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.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
|
||||
*/
|
||||
|
|
|
@ -18,7 +18,7 @@ package com.keylesspalace.tusky.util
|
|||
import android.util.Log
|
||||
import androidx.appcompat.app.AppCompatDelegate
|
||||
import androidx.core.os.LocaleListCompat
|
||||
import com.keylesspalace.tusky.db.AccountEntity
|
||||
import com.keylesspalace.tusky.db.entity.AccountEntity
|
||||
import java.util.Locale
|
||||
|
||||
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.keylesspalace.tusky.MainActivity
|
||||
import com.keylesspalace.tusky.R
|
||||
import com.keylesspalace.tusky.db.AccountEntity
|
||||
import com.keylesspalace.tusky.db.AccountManager
|
||||
import com.keylesspalace.tusky.db.entity.AccountEntity
|
||||
import com.keylesspalace.tusky.di.ApplicationScope
|
||||
import javax.inject.Inject
|
||||
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
|
||||
|
||||
import android.content.SharedPreferences
|
||||
import com.keylesspalace.tusky.db.AccountEntity
|
||||
import com.keylesspalace.tusky.db.entity.AccountEntity
|
||||
import com.keylesspalace.tusky.settings.PrefKeys
|
||||
|
||||
data class StatusDisplayOptions(
|
||||
|
|
|
@ -36,10 +36,8 @@ package com.keylesspalace.tusky.util
|
|||
|
||||
import androidx.paging.CombinedLoadStates
|
||||
import androidx.paging.LoadState
|
||||
import com.keylesspalace.tusky.entity.Notification
|
||||
import com.keylesspalace.tusky.entity.Status
|
||||
import com.keylesspalace.tusky.entity.TrendingTag
|
||||
import com.keylesspalace.tusky.viewdata.NotificationViewData
|
||||
import com.keylesspalace.tusky.viewdata.StatusViewData
|
||||
import com.keylesspalace.tusky.viewdata.TranslationViewData
|
||||
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> {
|
||||
val maxTrendingValue = flatMap { tag -> tag.history }
|
||||
.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?
|
||||
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 */
|
||||
fun copyWithCollapsed(isCollapsed: Boolean): Concrete {
|
||||
return copy(isCollapsed = isCollapsed)
|
||||
|
|
|
@ -23,9 +23,9 @@ import androidx.work.CoroutineWorker
|
|||
import androidx.work.ForegroundInfo
|
||||
import androidx.work.WorkerParameters
|
||||
import com.keylesspalace.tusky.R
|
||||
import com.keylesspalace.tusky.components.notifications.NotificationFetcher
|
||||
import com.keylesspalace.tusky.components.notifications.NotificationHelper
|
||||
import com.keylesspalace.tusky.components.notifications.NotificationHelper.NOTIFICATION_ID_FETCH_NOTIFICATION
|
||||
import com.keylesspalace.tusky.components.systemnotifications.NotificationFetcher
|
||||
import com.keylesspalace.tusky.components.systemnotifications.NotificationHelper
|
||||
import com.keylesspalace.tusky.components.systemnotifications.NotificationHelper.NOTIFICATION_ID_FETCH_NOTIFICATION
|
||||
import javax.inject.Inject
|
||||
|
||||
/** Fetch and show new notifications. */
|
||||
|
|
|
@ -25,17 +25,17 @@ import androidx.work.ForegroundInfo
|
|||
import androidx.work.ListenableWorker
|
||||
import androidx.work.WorkerParameters
|
||||
import com.keylesspalace.tusky.R
|
||||
import com.keylesspalace.tusky.components.notifications.NotificationHelper
|
||||
import com.keylesspalace.tusky.components.notifications.NotificationHelper.NOTIFICATION_ID_PRUNE_CACHE
|
||||
import com.keylesspalace.tusky.components.systemnotifications.NotificationHelper
|
||||
import com.keylesspalace.tusky.components.systemnotifications.NotificationHelper.NOTIFICATION_ID_PRUNE_CACHE
|
||||
import com.keylesspalace.tusky.db.AccountManager
|
||||
import com.keylesspalace.tusky.db.AppDatabase
|
||||
import com.keylesspalace.tusky.db.DatabaseCleaner
|
||||
import javax.inject.Inject
|
||||
|
||||
/** Prune the database cache of old statuses. */
|
||||
class PruneCacheWorker(
|
||||
appContext: Context,
|
||||
workerParams: WorkerParameters,
|
||||
private val appDatabase: AppDatabase,
|
||||
private val databaseCleaner: DatabaseCleaner,
|
||||
private val accountManager: AccountManager
|
||||
) : CoroutineWorker(appContext, workerParams) {
|
||||
val notification: Notification = NotificationHelper.createWorkerNotification(
|
||||
|
@ -46,7 +46,7 @@ class PruneCacheWorker(
|
|||
override suspend fun doWork(): Result {
|
||||
for (account in accountManager.accounts) {
|
||||
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()
|
||||
}
|
||||
|
@ -58,16 +58,17 @@ class PruneCacheWorker(
|
|||
|
||||
companion object {
|
||||
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"
|
||||
}
|
||||
|
||||
class Factory @Inject constructor(
|
||||
private val appDatabase: AppDatabase,
|
||||
private val databaseCleaner: DatabaseCleaner,
|
||||
private val accountManager: AccountManager
|
||||
) : ChildWorkerFactory {
|
||||
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"?>
|
||||
<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:tools="http://schemas.android.com/tools"
|
||||
android:id="@+id/notification_report"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:paddingLeft="14dp"
|
||||
android:paddingRight="14dp">
|
||||
android:paddingStart="14dp"
|
||||
android:paddingEnd="14dp"
|
||||
android:paddingBottom="14dp">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/notification_top_text"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:layout_constraintLeft_toLeftOf="parent"
|
||||
android:layout_marginTop="8dp"
|
||||
android:drawablePadding="10dp"
|
||||
android:ellipsize="end"
|
||||
|
@ -23,19 +22,23 @@
|
|||
android:paddingStart="28dp"
|
||||
android:textColor="?android:textColorSecondary"
|
||||
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" />
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/notification_reportee_avatar"
|
||||
android:layout_width="48dp"
|
||||
android:layout_height="48dp"
|
||||
app:layout_constraintTop_toBottomOf="@id/notification_top_text"
|
||||
app:layout_constraintLeft_toLeftOf="parent"
|
||||
android:layout_marginTop="10dp"
|
||||
android:layout_marginEnd="14dp"
|
||||
android:layout_marginBottom="14dp"
|
||||
android:contentDescription="@string/action_view_profile"
|
||||
android:paddingEnd="12dp"
|
||||
android:paddingBottom="12dp"
|
||||
android:scaleType="centerCrop"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@id/notification_top_text"
|
||||
tools:ignore="RtlHardcoded,RtlSymmetry"
|
||||
tools:src="@drawable/avatar_default" />
|
||||
|
||||
|
@ -44,40 +47,38 @@
|
|||
android:layout_width="24dp"
|
||||
android:layout_height="24dp"
|
||||
android:contentDescription="@string/action_view_profile"
|
||||
app:layout_constraintRight_toRightOf="@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
|
||||
android:id="@+id/notification_summary"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="-4dp"
|
||||
android:layout_marginStart="14dp"
|
||||
app:layout_constraintTop_toTopOf="@id/notification_reportee_avatar"
|
||||
app:layout_constraintLeft_toRightOf="@id/notification_reporter_avatar"
|
||||
android:importantForAccessibility="no"
|
||||
android:layout_marginTop="6dp"
|
||||
android:hyphenationFrequency="full"
|
||||
android:importantForAccessibility="no"
|
||||
android:lineSpacingMultiplier="1.1"
|
||||
android:textColor="?android:textColorTertiary"
|
||||
android:textSize="?attr/status_text_medium"
|
||||
tools:ignore="NegativeMargin"
|
||||
tools:text="30 minutes ago - 2 posts" />
|
||||
app:layout_constraintStart_toEndOf="@id/notification_reporter_avatar"
|
||||
app:layout_constraintTop_toBottomOf="@id/notification_top_text"
|
||||
tools:text="30 minutes ago · 2 posts attached" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/notification_category"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
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:importantForAccessibility="no"
|
||||
android:lineSpacingMultiplier="1.1"
|
||||
android:paddingBottom="10dp"
|
||||
android:textColor="?android:textColorTertiary"
|
||||
android:textSize="?attr/status_text_medium"
|
||||
android:textStyle="bold"
|
||||
app:layout_constraintStart_toEndOf="@id/notification_reporter_avatar"
|
||||
app:layout_constraintTop_toBottomOf="@id/notification_summary"
|
||||
tools:text="Spam" />
|
||||
|
||||
</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