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:
Konrad Pozniak 2024-05-03 18:27:10 +02:00 committed by GitHub
parent 3bbf96b057
commit b2c0b18c8e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
121 changed files with 6992 additions and 4654 deletions

View File

@ -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=&quot;true&quot;, otherwise pick a different name.">
message="Overriding `@layout/exo_player_control_view` which is marked as private in androidx.media3:media3-ui:1.3.1. If deliberate, use tools:override=&quot;true&quot;, otherwise pick a different name.">
<location
file="src/main/res/layout/exo_player_control_view.xml"/>
</issue>
<issue
id="PrivateResource"
message="The resource `@color/exo_bottom_bar_background` is marked as private in androidx.media3:media3-ui:1.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=&quot;@color/exo_bottom_bar_background&quot;"
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=&quot;@dimen/exo_styled_controls_padding&quot;"
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=" &lt;include layout=&quot;@layout/exo_player_control_rewind_button&quot; />"
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=" &lt;include layout=&quot;@layout/exo_player_control_ffwd_button&quot; />"
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=&quot;@dimen/exo_styled_bottom_bar_height&quot;"
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=&quot;@dimen/exo_styled_bottom_bar_margin_top&quot;"
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=&quot;@color/exo_bottom_bar_background&quot;"
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=&quot;@dimen/exo_styled_bottom_bar_time_padding&quot;"
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=&quot;@dimen/exo_styled_bottom_bar_time_padding&quot;"
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=&quot;@dimen/exo_styled_bottom_bar_time_padding&quot;"
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=&quot;@dimen/exo_styled_bottom_bar_time_padding&quot;"
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=&quot;@dimen/exo_styled_progress_layout_height&quot;"
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=&quot;@dimen/exo_styled_progress_margin_bottom&quot;/>"
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=&quot;@dimen/exo_styled_minimal_controls_margin_bottom&quot;"
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(&quot;Failed to %s account id %s&quot;, accept ? &quot;accept&quot; : &quot;reject&quot;, 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(&quot;https://%s/admin/reports/%s&quot;, 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&lt;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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -44,6 +44,7 @@ class FollowRequestsAdapter(
)
return FollowRequestViewHolder(
binding,
accountActionListener,
linkListener,
showHeader = false
)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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. */

View File

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

View File

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