From 4d401c787858f7dbe2f799591f93c361b3b1d5a0 Mon Sep 17 00:00:00 2001 From: Nik Clayton Date: Fri, 10 Mar 2023 20:12:33 +0100 Subject: [PATCH] Convert NotificationsFragment and related code to Kotlin, use the Paging library (#3159) * Unmodified output from "Convert Java to Kotlin" on NotificationsFragment.java * Bare minimum changes to get this to compile and run - Use `lateinit` for `eventhub`, `adapter`, `preferences`, and `scrolllistener` - Removed override for accountManager, it can be used from the superclass - Add `?.` where non-nullity could not (yet) be guaranteed - Remove `?` from type lists where non-nullity is guaranteed - Explicitly convert lists to mutable where necessary - Delete unused function `findReplyPosition` * Remove all unnecessary non-null (!!) assertions The previous change meant some values are no longer nullable. Remove the non-null assertions. * Lint ListStatusAccessibilityDelegate call - Remove redundant constructor - Move block outside of `()` * Use `let` when handling compose button visibility on scroll * Replace a `requireNonNull` with `!!` * Remove redundant return values * Remove or rename unused lambda parameters * Remove unnecessary type parameters * Remove unnecessary null checks * Replace cascading-if statement with `when` * Simplify calculation of `topId` * Use more appropriate list properties and methods - Access the last value with `.last()` - Access the last index with `.lastIndex` - Replace logical-chain with `asRightOrNull` and `?.` - `.isNotEmpty()`, not `!...isEmpty()` * Inline unnecessary variable * Use PrefKeys constants instead of bare strings * Use `requireContext()` instead of `context!!` * Replace deprecated `onActivityCreated()` with `onViewCreated()` * Remove unnecessary variable setting * Replace `size == 0` check with `isEmpty()` * Format with ktlint, no functionality changes * Convert NotifcationsAdapter to Kotlin Does not compile, this is the unchanged output of the "Convert to Kotlin" function * Minimum changes to get NotificationsAdapter to compile * Remove unnecessary visibility modifiers * Use `isNotEmpty()` * Remove unused lambda parameters * Convert cascading-if to `when` * Simplifiy assignment op * Use explicit argument names with `copy()` * Use `.firstOrNull()` instead of `if` * Mark as lateinit to avoid unnecessary null checks * Format with ktlint, whitespace changes only * Bare minimum necessary to demonstrate paging in notifications Create `NotificationsPagingSource`. This uses a new `notifications2()` API call, which will exist until all the code has been adapted. Instead of using placeholders, Create `NotificationsPagingAdapter` (will replace `NotificationsAdapater`) to consume this data. Expose the paging source view a new `NotificationsViewModel` `flow`, and submit new pages to the adapter as they are available in `NotificationsFragment`. Comment out any other code in `NotificationsFragment` that deals with loading data from the network. This will be updated as necessary, either here, or in the view model. Lots of functionality is missing, including: - Different views for different notification types - Starting at the remembered notification position - Interacting with notifications - Adjusting the UI state to match the loading state These will be added incrementally. * Migrate StatusNotificationViewHolder impl. to NotificationsPagingAdapter With this change `NotificationsPagingAdapter` shows notifications about a status correctly. - Introduce a `ViewHolder` abstract class that all Notification view holders derive from. Modify the fallback view holder to use this. - Implement `StatusNotificationViewHolder`. Much of the code is from the existing implementation in the `NotificationAdapater`. - The original code split the code that binds values to views between the adapter's `bindViewHolder` method and the view holder's methods. In this code, all of the binding code is in the view holder, in a `bind` method. This is called by the adapter's `bindViewHolder` method. This keeps all the binding logic in the view holder, where it belongs. - The new `StatusNotificationViewHolder` uses view binding to access its views instead of `findViewById`. - Logically, information about whether to show sensitive media, or open content warnings should be part of the `StatusDisplayOptions`. So add those as fields, and populate them appropriately. This affects code outside notification handling, which will be adjusted later. * Note some TODOs to complete before the PR is finished * Extract StatusNotificationViewHolder to a new file * Add TODO for NotificationViewData.Concrete * Convert the adapter to take NotificationViewData.Concrete * Add a view holder for regular status notifications * Migrate Follow and FollowRequest notifications * Migrate report notifications * Convert onViewThread to use the adapter data * Convert onViewMedia to use the adapter data * Convert onMore to use the adapter data * Convert onReply to use the adapter data * Convert NotificationViewData to Kotlin * Re-implement the reblog functionality - Move reblogging in to the view model - Update the UI via the adapter's `snapshot()` and `notifyItemChanged()` methods * Re-implement the favourite functionality Same approach as reblog * Re-implement the bookmark functionality Same approach as reblog * Add TODO re StatusActionListener interface * Add TODO re event handling * Re-implementing the voting functionality * Re-implement viewing hidden content - Hidden media - Content behind a content warning * Add a TODO re pinning * Re-implement "Show more" / "Show less" * Delete unused updateStatus() function * Comment out the scroll listener for the moment * Re-implement applying filters to notifications Introduce `NotificationsRepository`, to provide access to the notifications stream. When changing the filters the flow is as follows: - User clicks "Apply" in the fragment. - Fragment calls `viewModel.accept()` with a `UiAction.ApplyFilter` (new class). - View model maintains a private flow of incoming UI actions. The new action is emitted to that flow. - In view model, `notificationFilter` waits for `.ApplyFilter` actions, and ensures the filter is saved, then emits it. - In view model, `pagingDataFlow` waits for new items from `notificationsFilter` and fetches the notifications from the repository in response. The repository provides `Notification`, so the model maps them to `NotificationViewData.Concrete` for display by the adapter. - In view model the UI state also waits for new items from `notificationsFilter` and emits a new `UiState` every time the filter is changed. When opening the fragment for the first time: - All of the above machinery, but `notificationFilter` also fetches the filter from the active account and emits that first. This triggers the first fetch and the first update of `uiState`. Also: - Add TODOs for functionality that is not implemented yet - Delete a lot of dead code from NotificationsFragment * Include important preference values in `uiState` Listen to the flow of eventHub events, filtered to preference changes that are relevant to the notification view. When preferences change (or when the view model starts), fetch the current values, and include them in `uiState`. Remove preference handling from `NotificationsFragment`, and just use the values from `uiState`. Adjust how the `useAbsoluteTime` preference is handled. The previous code loaded new content (via a diffutil) in to the adapter, which would trigger a re-binding of the timestamp. As the adapter content is immutable, the new code simply triggers a re-binding of the views that are currently visible on screen. * Update UI in response to different load states Notifications can be loaded at the top and bottom of the timeline. Add a new layout to show the progress of these loads, and any errors that can occur. Catch network errors in `NotificationsPagingSource` and convert to `LoadState.Error`. Add a header/footer to the notifications list to show the load state. Collect the load state from the adapter, use this to drive the visibility of different views. * Save and restore the last read notification ID Use this when fetching notifications, to centre the list around the notification that was last read. * Call notifyItemRangeChanged with the correct parameters * Don't try and save list position if there are no items in the list * Show/hide the "Nothing to see" view appropriately * Update comments * Handle the case where the notification key no longer exists * Re-implement support for showMediaPreview and other settings * Re-implement "hide FAB when scrolling" preference * Delete dead code * Delete Notifications Adapater and Placeholder types * Remove NotificationViewData.Concrete subclass Now there's no Placeholder, everything is a NotificationViewData. * Improve how notification pages are loaded if the first notification is missing or filtered * Re-implement clear notifications, show errors * s/default/from/ * Add missing headers * Don't process bookmarking via EventHub - Initiating a bookmark is triggered by the fragment sending a StatusUiAction.Bookmark - View model receives this, makes API call, waits for response, emits either a success or failure state - Fragment collects success/failure states, updates the UI accordingly * Don't process favourites via EventHub * Don't process reblog via EventHub * Don't process poll votes with EventHub This removes EventHub from the fragment * Respond to follow requests via the view model * Docs and cleanup * Typo and editing pass * Minor edits for clarity * Remove newline in diagram * Reorder sequence diagram * s/authorize/accept/ * s/pagingDataFlow/pagingData/ * Add brief KDoc * Try and fetch a full first page of notifications * Call the API method `notifications` again * Log UI errors at the point of handling * Remove unused variable * Replace String.format() with interpolation * Convert NotificationViewData to data class * Rename copy() to make(), to avoid confusion with default copy() method * Lint * Update app/src/main/res/layout/simple_list_item_1.xml * Update app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsPagingAdapter.kt * Update app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsViewModel.kt * Update app/src/main/java/com/keylesspalace/tusky/fragment/NotificationsFragment.kt * Update app/src/main/java/com/keylesspalace/tusky/viewdata/NotificationViewData.kt * Initial NotificationsViewModel tests * Add missing import * More tests, some cleanup * Comments, re-order some code * Set StateRestorationPolicy.PREVENT_WHEN_EMPTY * Mark clearNotifications() as "suspend" * Catch exceptions from clearNotifications and emit * Update TODOs with explanations * Ensure initial fetch uses a null ID * Stop/start collecting pagingData based on the lifecycle * Don't hide the list while refreshing * Refresh notifications on mutes and blocks * Update tests now clearNotifications is a suspend fun * Add "Refresh" menu to NotificationsFragment * Use account.name over account.displayName * Update app/src/main/java/com/keylesspalace/tusky/fragment/NotificationsFragment.kt Co-authored-by: Konrad Pozniak * Mark layoutmanager as lateinit * Mark layoutmanager as lateinit * Refactor generating UI text * Add Copyright header * Correctly apply notification filters * Show follow request header in notifications * Wait for follow request actions to complete, so the reqeuest is sent * Remove duplicate copyright header * Revert copyright change in unmodified file * Null check response body * Move NotificationsFragment to component.notifications * Use viewlifecycleowner.lifecyclescope * Show notification filter as a dialog rather than a popup window The popup window: - Is inconsistent UI - Requires a custom layout - Didn't play nicely with viewbinding * Refresh adapter on block/mute * Scroll up slightly when new content is loaded * Restore progressbar * Lint * Update app/src/main/res/layout/simple_list_item_1.xml --------- Co-authored-by: Konrad Pozniak --- app/build.gradle | 2 + .../java/com/keylesspalace/tusky/TabData.kt | 2 +- .../tusky/adapter/FollowRequestViewHolder.kt | 47 +- .../tusky/adapter/NotificationsAdapter.java | 691 --------- .../adapter/ReportNotificationViewHolder.kt | 75 +- .../tusky/adapter/StatusViewHolder.java | 4 +- .../adapter/FollowRequestsAdapter.kt | 10 +- .../conversation/ConversationsFragment.kt | 4 +- .../notifications/FollowViewHolder.kt | 100 ++ .../notifications/NotificationsFragment.kt | 681 +++++++++ .../NotificationsLoadStateAdapter.kt | 38 + .../NotificationsLoadStateViewHolder.kt | 73 + .../NotificationsPagingAdapter.kt | 209 +++ .../NotificationsPagingSource.kt | 184 +++ .../notifications/NotificationsRepository.kt | 74 + .../notifications/NotificationsViewModel.kt | 522 +++++++ .../notifications/PushNotificationHelper.kt | 2 +- .../StatusNotificationViewHolder.kt | 385 +++++ .../notifications/StatusViewHolder.kt | 60 + .../fragments/ReportStatusesFragment.kt | 4 +- .../fragments/SearchStatusesFragment.kt | 8 +- .../components/timeline/TimelineFragment.kt | 10 +- .../viewthread/ViewThreadFragment.kt | 4 +- .../keylesspalace/tusky/db/AccountEntity.kt | 5 + .../tusky/di/FragmentBuildersModule.kt | 2 +- .../tusky/di/ViewModelFactory.kt | 23 + .../tusky/entity/Notification.kt | 56 +- .../tusky/fragment/NotificationsFragment.java | 1273 ----------------- .../tusky/network/MastodonApi.kt | 24 +- .../tusky/usecase/TimelineCases.kt | 9 + .../tusky/util/StatusDisplayOptions.kt | 106 +- .../keylesspalace/tusky/util/ViewDataUtils.kt | 21 +- .../tusky/viewdata/NotificationViewData.java | 138 -- .../tusky/viewdata/NotificationViewData.kt | 43 + .../tusky/viewdata/StatusViewData.kt | 15 - .../fragment_timeline_notifications.xml | 19 +- .../fragment_timeline_notifications.xml | 17 + ...m_notifications_load_state_footer_view.xml | 45 + .../main/res/layout/notifications_filter.xml | 19 - .../main/res/layout/simple_list_item_1.xml | 27 + .../main/res/menu/fragment_notifications.xml | 17 + app/src/main/res/values/strings.xml | 35 + .../com/keylesspalace/tusky/FilterTest.kt | 148 +- .../NotificationsViewModelTestBase.kt | 137 ++ ...icationsViewModelTestClearNotifications.kt | 65 + .../NotificationsViewModelTestFilter.kt | 66 + ...icationsViewModelTestNotificationAction.kt | 144 ++ .../NotificationsViewModelTestStatusAction.kt | 227 +++ ...ationsViewModelTestStatusDisplayOptions.kt | 102 ++ .../NotificationsViewModelTestUiState.kt | 88 ++ .../NotificationsViewModelTestVisibleId.kt | 43 + doc/ViewModelInterface.md | 615 ++++++++ gradle/libs.versions.toml | 4 + 53 files changed, 4460 insertions(+), 2262 deletions(-) delete mode 100644 app/src/main/java/com/keylesspalace/tusky/adapter/NotificationsAdapter.java create mode 100644 app/src/main/java/com/keylesspalace/tusky/components/notifications/FollowViewHolder.kt create mode 100644 app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsFragment.kt create mode 100644 app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsLoadStateAdapter.kt create mode 100644 app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsLoadStateViewHolder.kt create mode 100644 app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsPagingAdapter.kt create mode 100644 app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsPagingSource.kt create mode 100644 app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsRepository.kt create mode 100644 app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsViewModel.kt create mode 100644 app/src/main/java/com/keylesspalace/tusky/components/notifications/StatusNotificationViewHolder.kt create mode 100644 app/src/main/java/com/keylesspalace/tusky/components/notifications/StatusViewHolder.kt delete mode 100644 app/src/main/java/com/keylesspalace/tusky/fragment/NotificationsFragment.java delete mode 100644 app/src/main/java/com/keylesspalace/tusky/viewdata/NotificationViewData.java create mode 100644 app/src/main/java/com/keylesspalace/tusky/viewdata/NotificationViewData.kt create mode 100644 app/src/main/res/layout/item_notifications_load_state_footer_view.xml delete mode 100644 app/src/main/res/layout/notifications_filter.xml create mode 100644 app/src/main/res/layout/simple_list_item_1.xml create mode 100644 app/src/test/java/com/keylesspalace/tusky/components/notifications/NotificationsViewModelTestBase.kt create mode 100644 app/src/test/java/com/keylesspalace/tusky/components/notifications/NotificationsViewModelTestClearNotifications.kt create mode 100644 app/src/test/java/com/keylesspalace/tusky/components/notifications/NotificationsViewModelTestFilter.kt create mode 100644 app/src/test/java/com/keylesspalace/tusky/components/notifications/NotificationsViewModelTestNotificationAction.kt create mode 100644 app/src/test/java/com/keylesspalace/tusky/components/notifications/NotificationsViewModelTestStatusAction.kt create mode 100644 app/src/test/java/com/keylesspalace/tusky/components/notifications/NotificationsViewModelTestStatusDisplayOptions.kt create mode 100644 app/src/test/java/com/keylesspalace/tusky/components/notifications/NotificationsViewModelTestUiState.kt create mode 100644 app/src/test/java/com/keylesspalace/tusky/components/notifications/NotificationsViewModelTestVisibleId.kt create mode 100644 doc/ViewModelInterface.md diff --git a/app/build.gradle b/app/build.gradle index d1259f72e..2ccfcebf2 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -167,6 +167,8 @@ dependencies { testImplementation libs.androidx.core.testing testImplementation libs.kotlinx.coroutines.test testImplementation libs.androidx.work.testing + testImplementation libs.truth + testImplementation libs.turbine androidTestImplementation libs.espresso.core androidTestImplementation libs.androidx.room.testing diff --git a/app/src/main/java/com/keylesspalace/tusky/TabData.kt b/app/src/main/java/com/keylesspalace/tusky/TabData.kt index 43e595004..09d1f1cf4 100644 --- a/app/src/main/java/com/keylesspalace/tusky/TabData.kt +++ b/app/src/main/java/com/keylesspalace/tusky/TabData.kt @@ -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.TrendingFragment -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 */ diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/FollowRequestViewHolder.kt b/app/src/main/java/com/keylesspalace/tusky/adapter/FollowRequestViewHolder.kt index 7e675de33..b12b71705 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/FollowRequestViewHolder.kt +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/FollowRequestViewHolder.kt @@ -21,18 +21,41 @@ 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.NotificationsPagingAdapter import com.keylesspalace.tusky.databinding.ItemFollowRequestBinding 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.loadAvatar 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 accountActionListener: AccountActionListener, private val showHeader: Boolean -) : RecyclerView.ViewHolder(binding.root) { +) : NotificationsPagingAdapter.ViewHolder, RecyclerView.ViewHolder(binding.root) { + + override fun bind( + viewData: NotificationViewData, + payloads: List<*>?, + statusDisplayOptions: StatusDisplayOptions + ) { + // Skip updates with payloads. That indicates a timestamp update, and + // this view does not have timestamps. + if (!payloads.isNullOrEmpty()) return + + setupWithAccount( + viewData.account, + statusDisplayOptions.animateAvatars, + statusDisplayOptions.animateEmojis, + statusDisplayOptions.showBotOverlay + ) + + setupActionListener(accountActionListener, viewData.account.id) + } fun setupWithAccount( account: TimelineAccount, @@ -41,18 +64,32 @@ class FollowRequestViewHolder( showBotOverlay: Boolean ) { val wrappedName = account.name.unicodeWrap() - val emojifiedName: CharSequence = wrappedName.emojify(account.emojis, itemView, animateEmojis) + val emojifiedName: CharSequence = wrappedName.emojify( + account.emojis, + itemView, + animateEmojis + ) binding.displayNameTextView.text = emojifiedName if (showHeader) { - val wholeMessage: String = itemView.context.getString(R.string.notification_follow_request_format, wrappedName) + val wholeMessage: String = itemView.context.getString( + R.string.notification_follow_request_format, + wrappedName + ) binding.notificationTextView.text = SpannableStringBuilder(wholeMessage).apply { - setSpan(StyleSpan(Typeface.BOLD), 0, wrappedName.length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) + setSpan( + StyleSpan(Typeface.BOLD), + 0, + wrappedName.length, + Spanned.SPAN_EXCLUSIVE_EXCLUSIVE + ) }.emojify(account.emojis, itemView, animateEmojis) } binding.notificationTextView.visible(showHeader) val formattedUsername = itemView.context.getString(R.string.post_username_format, account.username) binding.usernameTextView.text = formattedUsername - val avatarRadius = binding.avatar.context.resources.getDimensionPixelSize(R.dimen.avatar_radius_48dp) + val avatarRadius = binding.avatar.context.resources.getDimensionPixelSize( + R.dimen.avatar_radius_48dp + ) loadAvatar(account.avatar, binding.avatar, avatarRadius, animateAvatar) binding.avatarBadge.visible(showBotOverlay && account.bot) } diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/NotificationsAdapter.java b/app/src/main/java/com/keylesspalace/tusky/adapter/NotificationsAdapter.java deleted file mode 100644 index 87096232b..000000000 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/NotificationsAdapter.java +++ /dev/null @@ -1,691 +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 . */ - -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 { - - public interface AdapterDataSource { - 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 dataSource; - private final AbsoluteTimeFormatter absoluteTimeFormatter = new AbsoluteTimeFormatter(); - - public NotificationsAdapter(String accountId, - AdapterDataSource 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, 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 payloads) { - bindViewHolder(viewHolder, position, payloads); - } - - private void bindViewHolder(@NonNull RecyclerView.ViewHolder viewHolder, int position, @Nullable List 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() - ); - } - - 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()); - - } - - 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 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); - 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 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("%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()); - - 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()); - - notificationAvatar.setVisibility(View.VISIBLE); - ImageLoadingHelper.loadAvatar(notificationAvatarUrl, notificationAvatar, - avatarRadius24dp, statusDisplayOptions.animateAvatars()); - } - - @Override - public void onClick(View v) { - switch (v.getId()) { - case R.id.notification_container: - case R.id.notification_content: - if (notificationActionListener != null) - notificationActionListener.onViewStatusForNotificationId(notificationId); - break; - case R.id.notification_top_text: - if (notificationActionListener != null) - notificationActionListener.onViewAccount(accountId); - break; - } - } - - 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 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; - if (statusViewData.getSpoilerText() != null) { - emojifiedContentWarning = CustomEmojiHelper.emojify( - statusViewData.getSpoilerText(), - statusViewData.getActionable().getEmojis(), - contentWarningDescriptionTextView, - statusDisplayOptions.animateEmojis() - ); - } else { - emojifiedContentWarning = ""; - } - contentWarningDescriptionTextView.setText(emojifiedContentWarning); - } - - } -} diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/ReportNotificationViewHolder.kt b/app/src/main/java/com/keylesspalace/tusky/adapter/ReportNotificationViewHolder.kt index db2f79a99..d4712159a 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/ReportNotificationViewHolder.kt +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/ReportNotificationViewHolder.kt @@ -20,28 +20,76 @@ 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.components.notifications.NotificationActionListener +import com.keylesspalace.tusky.components.notifications.NotificationsPagingAdapter import com.keylesspalace.tusky.databinding.ItemReportNotificationBinding import com.keylesspalace.tusky.entity.Report import com.keylesspalace.tusky.entity.TimelineAccount +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 import java.util.Date class ReportNotificationViewHolder( private val binding: ItemReportNotificationBinding, -) : RecyclerView.ViewHolder(binding.root) { + private val notificationActionListener: NotificationActionListener, +) : NotificationsPagingAdapter.ViewHolder, RecyclerView.ViewHolder(binding.root) { - fun setupWithReport(reporter: TimelineAccount, report: Report, animateAvatar: Boolean, animateEmojis: Boolean) { - 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) + override fun bind( + viewData: NotificationViewData, + payloads: List<*>?, + statusDisplayOptions: StatusDisplayOptions + ) { + // Skip updates with payloads. That indicates a timestamp update, and + // this view does not have timestamps. + if (!payloads.isNullOrEmpty()) return + + setupWithReport( + viewData.account, + viewData.report!!, + statusDisplayOptions.animateAvatars, + statusDisplayOptions.animateEmojis + ) + setupActionListener( + notificationActionListener, + viewData.report.targetAccount.id, + viewData.account.id, + viewData.report.id + ) + } + + private fun setupWithReport( + reporter: TimelineAccount, + report: Report, + animateAvatar: Boolean, + animateEmojis: Boolean + ) { + val reporterName = reporter.name.unicodeWrap().emojify( + reporter.emojis, + binding.root, + animateEmojis + ) + val reporteeName = report.targetAccount.name.unicodeWrap().emojify( + report.targetAccount.emojis, + itemView, + animateEmojis + ) + val icon = ContextCompat.getDrawable(binding.root.context, R.drawable.ic_flag_24dp) binding.notificationTopText.setCompoundDrawablesWithIntrinsicBounds(icon, null, null, null) - binding.notificationTopText.text = itemView.context.getString(R.string.notification_header_report_format, reporterName, reporteeName) - binding.notificationSummary.text = itemView.context.getString(R.string.notification_summary_report_format, getRelativeTimeSpanString(itemView.context, report.createdAt.time, Date().time), report.status_ids?.size ?: 0) + 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, Date().time), + report.status_ids?.size ?: 0 + ) binding.notificationCategory.text = getTranslatedCategory(itemView.context, report.category) // Fancy avatar inset @@ -52,17 +100,22 @@ class ReportNotificationViewHolder( report.targetAccount.avatar, binding.notificationReporteeAvatar, itemView.context.resources.getDimensionPixelSize(R.dimen.avatar_radius_36dp), - animateAvatar, + animateAvatar ) loadAvatar( reporter.avatar, binding.notificationReporterAvatar, itemView.context.resources.getDimensionPixelSize(R.dimen.avatar_radius_24dp), - animateAvatar, + animateAvatar ) } - fun setupActionListener(listener: NotificationActionListener, reporteeId: String, reporterId: String, reportId: String) { + private fun setupActionListener( + listener: NotificationActionListener, + reporteeId: String, + reporterId: String, + reportId: String + ) { binding.notificationReporteeAvatar.setOnClickListener { val position = bindingAdapterPosition if (position != RecyclerView.NO_POSITION) { diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/StatusViewHolder.java b/app/src/main/java/com/keylesspalace/tusky/adapter/StatusViewHolder.java index 18a669a16..b1881272d 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/StatusViewHolder.java +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/StatusViewHolder.java @@ -93,7 +93,7 @@ public class StatusViewHolder extends StatusBaseViewHolder { } // don't use this on the same ViewHolder as setRebloggedByDisplayName, will cause recycling issues as paddings are changed - void setPollInfo(final boolean ownPoll) { + protected void setPollInfo(final boolean ownPoll) { statusInfo.setText(ownPoll ? R.string.poll_ended_created : R.string.poll_ended_voted); statusInfo.setCompoundDrawablesRelativeWithIntrinsicBounds(R.drawable.ic_poll_24dp, 0, 0, 0); statusInfo.setCompoundDrawablePadding(Utils.dpToPx(statusInfo.getContext(), 10)); @@ -101,7 +101,7 @@ public class StatusViewHolder extends StatusBaseViewHolder { statusInfo.setVisibility(View.VISIBLE); } - void hideStatusInfo() { + protected void hideStatusInfo() { statusInfo.setVisibility(View.GONE); } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/accountlist/adapter/FollowRequestsAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/accountlist/adapter/FollowRequestsAdapter.kt index 35a59a8e4..ab20d748a 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/accountlist/adapter/FollowRequestsAdapter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/accountlist/adapter/FollowRequestsAdapter.kt @@ -35,8 +35,14 @@ class FollowRequestsAdapter( ) { override fun createAccountViewHolder(parent: ViewGroup): FollowRequestViewHolder { - val binding = ItemFollowRequestBinding.inflate(LayoutInflater.from(parent.context), parent, false) - return FollowRequestViewHolder(binding, false) + val binding = ItemFollowRequestBinding.inflate( + LayoutInflater.from(parent.context), parent, false + ) + return FollowRequestViewHolder( + binding, + accountActionListener, + showHeader = false + ) } override fun onBindAccountViewHolder(viewHolder: FollowRequestViewHolder, position: Int) { diff --git a/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsFragment.kt index fc5cebe6b..9ce9604b2 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsFragment.kt @@ -110,7 +110,9 @@ class ConversationsFragment : confirmReblogs = preferences.getBoolean("confirmReblogs", true), confirmFavourites = preferences.getBoolean("confirmFavourites", false), hideStats = preferences.getBoolean(PrefKeys.WELLBEING_HIDE_STATS_POSTS, false), - animateEmojis = preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false) + animateEmojis = preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false), + showSensitiveMedia = accountManager.activeAccount!!.alwaysShowSensitiveMedia, + openSpoiler = accountManager.activeAccount!!.alwaysOpenSpoiler ) adapter = ConversationAdapter(statusDisplayOptions, this) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/notifications/FollowViewHolder.kt b/app/src/main/java/com/keylesspalace/tusky/components/notifications/FollowViewHolder.kt new file mode 100644 index 000000000..ca19455ba --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/notifications/FollowViewHolder.kt @@ -0,0 +1,100 @@ +/* + * 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 . + */ + +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.entity.TimelineAccount +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 notificationActionListener: NotificationActionListener, +) : NotificationsPagingAdapter.ViewHolder, RecyclerView.ViewHolder(binding.root) { + private val avatarRadius42dp = itemView.context.resources.getDimensionPixelSize( + R.dimen.avatar_radius_42dp + ) + + override fun bind( + viewData: NotificationViewData, + payloads: List<*>?, + statusDisplayOptions: StatusDisplayOptions + ) { + // Skip updates with payloads. That indicates a timestamp update, and + // this view does not have timestamps. + if (!payloads.isNullOrEmpty()) return + + setMessage( + viewData.account, + viewData.type === Notification.Type.SIGN_UP, + statusDisplayOptions.animateAvatars, + statusDisplayOptions.animateEmojis + ) + setupButtons(notificationActionListener, viewData.account.id) + } + + private fun setMessage( + account: TimelineAccount, + isSignUp: Boolean, + animateAvatars: Boolean, + animateEmojis: Boolean + ) { + val context = binding.notificationText.context + val format = + context.getString( + if (isSignUp) { + R.string.notification_sign_up_format + } else { + R.string.notification_follow_format + } + ) + val wrappedDisplayName = account.name.unicodeWrap() + val wholeMessage = String.format(format, wrappedDisplayName) + val emojifiedMessage = + wholeMessage.emojify( + account.emojis, + binding.notificationText, + animateEmojis + ) + binding.notificationText.text = emojifiedMessage + val username = context.getString(R.string.post_username_format, account.username) + binding.notificationUsername.text = username + val emojifiedDisplayName = wrappedDisplayName.emojify( + account.emojis, + binding.notificationUsername, + animateEmojis + ) + binding.notificationDisplayName.text = emojifiedDisplayName + loadAvatar( + account.avatar, + binding.notificationAvatar, + avatarRadius42dp, + animateAvatars + ) + } + + private fun setupButtons(listener: NotificationActionListener, accountId: String) { + binding.root.setOnClickListener { listener.onViewAccount(accountId) } + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsFragment.kt new file mode 100644 index 000000000..b79156ebf --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsFragment.kt @@ -0,0 +1,681 @@ +/* + * 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 . + */ + +package com.keylesspalace.tusky.components.notifications + +import android.app.Dialog +import android.content.DialogInterface +import android.os.Bundle +import android.util.Log +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 androidx.appcompat.app.AlertDialog +import androidx.coordinatorlayout.widget.CoordinatorLayout +import androidx.core.view.MenuProvider +import androidx.core.view.isVisible +import androidx.fragment.app.DialogFragment +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.DiffUtil +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.OnRefreshListener +import at.connyduck.sparkbutton.helpers.Utils +import com.google.android.material.appbar.AppBarLayout.ScrollingViewBehavior +import com.google.android.material.color.MaterialColors +import com.google.android.material.snackbar.Snackbar +import com.keylesspalace.tusky.R +import com.keylesspalace.tusky.adapter.StatusBaseViewHolder +import com.keylesspalace.tusky.databinding.FragmentTimelineNotificationsBinding +import com.keylesspalace.tusky.di.Injectable +import com.keylesspalace.tusky.di.ViewModelFactory +import com.keylesspalace.tusky.entity.Notification +import com.keylesspalace.tusky.entity.Status +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.util.ListStatusAccessibilityDelegate +import com.keylesspalace.tusky.util.openLink +import com.keylesspalace.tusky.util.viewBinding +import com.keylesspalace.tusky.viewdata.AttachmentViewData.Companion.list +import com.keylesspalace.tusky.viewdata.NotificationViewData +import com.mikepenz.iconics.IconicsDrawable +import com.mikepenz.iconics.typeface.library.googlematerial.GoogleMaterial +import com.mikepenz.iconics.utils.colorInt +import com.mikepenz.iconics.utils.sizeDp +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.distinctUntilChangedBy +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch +import java.io.IOException +import javax.inject.Inject + +class NotificationsFragment : + SFragment(), + StatusActionListener, + NotificationActionListener, + AccountActionListener, + OnRefreshListener, + MenuProvider, + Injectable, + ReselectableFragment { + + @Inject + lateinit var viewModelFactory: ViewModelFactory + + private val viewModel: NotificationsViewModel by viewModels { viewModelFactory } + + private val binding by viewBinding(FragmentTimelineNotificationsBinding::bind) + + private lateinit var adapter: NotificationsPagingAdapter + + private lateinit var layoutManager: LinearLayoutManager + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + adapter = NotificationsPagingAdapter( + notificationDiffCallback, + accountId = accountManager.activeAccount!!.accountId, + statusActionListener = this, + notificationActionListener = this, + accountActionListener = this, + statusDisplayOptions = viewModel.statusDisplayOptions.value + ) + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + return inflater.inflate(R.layout.fragment_timeline_notifications, container, false) + } + + private fun updateFilterVisibility(showFilter: Boolean) { + val params = binding.swipeRefreshLayout.layoutParams as CoordinatorLayout.LayoutParams + if (showFilter) { + binding.appBarOptions.setExpanded(true, false) + binding.appBarOptions.visibility = View.VISIBLE + // Set content behaviour to hide filter on scroll + params.behavior = ScrollingViewBehavior() + } else { + binding.appBarOptions.setExpanded(false, false) + binding.appBarOptions.visibility = View.GONE + // Clear behaviour to hide app bar + params.behavior = null + } + } + + 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() + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + requireActivity().addMenuProvider(this, viewLifecycleOwner, Lifecycle.State.RESUMED) + + // Setup the SwipeRefreshLayout. + binding.swipeRefreshLayout.setOnRefreshListener(this) + binding.swipeRefreshLayout.setColorSchemeResources(R.color.tusky_blue) + + // Setup the RecyclerView. + binding.recyclerView.setHasFixedSize(true) + layoutManager = LinearLayoutManager(context) + binding.recyclerView.layoutManager = layoutManager + binding.recyclerView.setAccessibilityDelegateCompat( + ListStatusAccessibilityDelegate( + binding.recyclerView, + this + ) { pos: Int -> + val notification = adapter.snapshot()[pos] + // We support replies only for now + if (notification is NotificationViewData) { + notification.statusViewData + } else { + null + } + } + ) + binding.recyclerView.addItemDecoration( + DividerItemDecoration( + context, + DividerItemDecoration.VERTICAL + ) + ) + + binding.recyclerView.addOnScrollListener(object : RecyclerView.OnScrollListener() { + val actionButton = (activity as ActionButtonActivity).actionButton + + override fun onScrolled(view: RecyclerView, dx: Int, dy: Int) { + actionButton?.let { fab -> + if (!viewModel.uiState.value.showFabWhileScrolling) { + if (dy > 0 && fab.isShown) { + fab.hide() // Hide when scrolling down + } else if (dy < 0 && !fab.isShown) { + fab.show() // Show when scrolling up + } + } else if (!fab.isShown) { + fab.show() + } + } + } + }) + + binding.recyclerView.adapter = adapter.withLoadStateHeaderAndFooter( + header = NotificationsLoadStateAdapter { adapter.retry() }, + footer = NotificationsLoadStateAdapter { adapter.retry() } + ) + + binding.buttonClear.setOnClickListener { confirmClearNotifications() } + binding.buttonFilter.setOnClickListener { showFilterDialog() } + (binding.recyclerView.itemAnimator as SimpleItemAnimator?)!!.supportsChangeAnimations = + false + + // Signal the user that a refresh has loaded new items above their current position + // by scrolling up slightly to disclose the new content + adapter.registerAdapterDataObserver(object : RecyclerView.AdapterDataObserver() { + override fun onItemRangeInserted(positionStart: Int, itemCount: Int) { + if (positionStart == 0 && adapter.itemCount != itemCount) { + binding.recyclerView.post { + binding.recyclerView.scrollBy(0, Utils.dpToPx(requireContext(), -30)) + } + } + } + }) + + /** + * Collect this flow to notify the adapter that the timestamps of the visible items have + * changed + */ + val updateTimestampFlow = flow { + while (true) { delay(60000); emit(Unit) } + }.onEach { + layoutManager.findFirstVisibleItemPosition().let { first -> + first == RecyclerView.NO_POSITION && return@let + val count = layoutManager.findLastVisibleItemPosition() - first + adapter.notifyItemRangeChanged( + first, + count, + listOf(StatusBaseViewHolder.Key.KEY_CREATED) + ) + } + } + + viewLifecycleOwner.lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.STARTED) { + launch { + viewModel.pagingData.collectLatest { pagingData -> + Log.d(TAG, "Submitting data to adapter") + adapter.submitData(pagingData) + } + } + + // Show errors from the view model as snack bars. + // + // Errors are shown: + // - Indefinitely, so the user has a chance to read and understand + // the message + // - With a max of 5 text lines, to allow space for longer errors. + // E.g., on a typical device, an error message like "Bookmarking + // post failed: Unable to resolve host 'mastodon.social': No + // address associated with hostname" is 3 lines. + // - With a "Retry" option if the error included a UiAction to retry. + launch { + viewModel.uiError.collect { error -> + Log.d(TAG, error.toString()) + val message = getString( + error.message, + error.exception.localizedMessage + ?: getString(R.string.ui_error_unknown) + ) + val snackbar = Snackbar.make( + // Without this the FAB will not move out of the way + (activity as ActionButtonActivity).actionButton ?: binding.root, + message, + Snackbar.LENGTH_INDEFINITE + ).setTextMaxLines(5) + error.action?.let { action -> + snackbar.setAction(R.string.action_retry) { + viewModel.accept(action) + } + } + snackbar.show() + + // The status view has pre-emptively updated its state to show + // that the action succeeded. Since it hasn't, re-bind the view + // to show the correct data. + error.action?.let { action -> + action is StatusAction || return@let + + val position = adapter.snapshot().indexOfFirst { + it?.statusViewData?.status?.id == (action as StatusAction).statusViewData.id + } + if (position != RecyclerView.NO_POSITION) { + adapter.notifyItemChanged(position) + } + } + } + } + + // Show successful notification action as brief snackbars, so the + // user is clear the action has happened. + launch { + viewModel.uiSuccess + .filterIsInstance() + .collect { + Snackbar.make( + (activity as ActionButtonActivity).actionButton ?: binding.root, + getString(it.msg), + Snackbar.LENGTH_SHORT + ).show() + + when (it) { + // The follow request is no longer valid, refresh the adapter to + // remove it. + is NotificationActionSuccess.AcceptFollowRequest, + is NotificationActionSuccess.RejectFollowRequest -> adapter.refresh() + } + } + } + + // Update adapter data when status actions are successful, and re-bind to update + // the UI. + launch { + viewModel.uiSuccess + .filterIsInstance() + .collect { + val indexedViewData = adapter.snapshot() + .withIndex() + .firstOrNull { notificationViewData -> + notificationViewData.value?.statusViewData?.status?.id == + it.action.statusViewData.id + } ?: return@collect + + val statusViewData = + indexedViewData.value?.statusViewData ?: return@collect + + val status = when (it) { + is StatusActionSuccess.Bookmark -> + statusViewData.status.copy(bookmarked = it.action.state) + is StatusActionSuccess.Favourite -> + statusViewData.status.copy(favourited = it.action.state) + is StatusActionSuccess.Reblog -> + statusViewData.status.copy(reblogged = it.action.state) + is StatusActionSuccess.VoteInPoll -> + statusViewData.status.copy( + poll = it.action.poll.votedCopy(it.action.choices) + ) + } + indexedViewData.value?.statusViewData = statusViewData.copy( + status = status + ) + + adapter.notifyItemChanged(indexedViewData.index) + } + } + + // Refresh adapter on mutes and blocks + launch { + viewModel.uiSuccess.collectLatest { + when (it) { + is UiSuccess.Block, is UiSuccess.Mute, is UiSuccess.MuteConversation -> + adapter.refresh() + else -> { /* nothing to do */ + } + } + } + } + + // Update filter option visibility from uiState + launch { + viewModel.uiState.collectLatest { updateFilterVisibility(it.showFilterOptions) } + } + + // Update status display from statusDisplayOptions. If the new options request + // relative time display collect the flow to periodically re-bind the UI. + launch { + viewModel.statusDisplayOptions + .collectLatest { + adapter.statusDisplayOptions = it + layoutManager.findFirstVisibleItemPosition().let { first -> + first == RecyclerView.NO_POSITION && return@let + val count = layoutManager.findLastVisibleItemPosition() - first + adapter.notifyItemRangeChanged( + first, + count, + null + ) + } + + if (!it.useAbsoluteTime) { + updateTimestampFlow.collect() + } + } + } + + // Update the UI from the loadState + adapter.loadStateFlow + .distinctUntilChangedBy { it.refresh } + .collect { loadState -> + binding.recyclerView.isVisible = true + binding.progressBar.isVisible = loadState.refresh is LoadState.Loading && + !binding.swipeRefreshLayout.isRefreshing + binding.swipeRefreshLayout.isRefreshing = + loadState.refresh is LoadState.Loading && !binding.progressBar.isVisible + + binding.statusView.isVisible = false + if (loadState.refresh is LoadState.NotLoading) { + if (adapter.itemCount == 0) { + binding.statusView.setup( + R.drawable.elephant_friend_empty, + R.string.message_empty + ) + binding.recyclerView.isVisible = false + binding.statusView.isVisible = true + } else { + binding.statusView.isVisible = false + } + } + + if (loadState.refresh is LoadState.Error) { + when ((loadState.refresh as LoadState.Error).error) { + is IOException -> { + binding.statusView.setup( + R.drawable.elephant_offline, + R.string.error_network + ) { adapter.retry() } + } + else -> { + binding.statusView.setup( + R.drawable.elephant_error, + R.string.error_generic + ) { adapter.retry() } + } + } + binding.recyclerView.isVisible = false + binding.statusView.isVisible = true + } + } + } + } + } + + override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) { + menuInflater.inflate(R.menu.fragment_notifications, menu) + menu.findItem(R.id.action_refresh)?.apply { + icon = IconicsDrawable(requireContext(), GoogleMaterial.Icon.gmd_refresh).apply { + sizeDp = 20 + colorInt = MaterialColors.getColor(binding.root, android.R.attr.textColorPrimary) + } + } + } + + override fun onMenuItemSelected(menuItem: MenuItem): Boolean { + return when (menuItem.itemId) { + R.id.action_refresh -> { + binding.swipeRefreshLayout.isRefreshing = true + onRefresh() + true + } + else -> false + } + } + + override fun onRefresh() { + binding.progressBar.isVisible = false + adapter.refresh() + } + + override fun onPause() { + super.onPause() + + // Save the ID of the first notification visible in the list + val position = layoutManager.findFirstVisibleItemPosition() + if (position >= 0) { + adapter.snapshot()[position]?.id?.let { id -> + viewModel.accept(InfallibleUiAction.SaveVisibleId(visibleId = id)) + } + } + } + + override fun onResume() { + super.onResume() + NotificationHelper.clearNotificationsForActiveAccount(requireContext(), accountManager) + } + + override fun onReply(position: Int) { + val status = adapter.peek(position)?.statusViewData?.status ?: return + super.reply(status) + } + + override fun onReblog(reblog: Boolean, position: Int) { + val statusViewData = adapter.peek(position)?.statusViewData ?: return + viewModel.accept(StatusAction.Reblog(reblog, statusViewData)) + } + + override fun onFavourite(favourite: Boolean, position: Int) { + val statusViewData = adapter.peek(position)?.statusViewData ?: return + viewModel.accept(StatusAction.Favourite(favourite, statusViewData)) + } + + override fun onBookmark(bookmark: Boolean, position: Int) { + val statusViewData = adapter.peek(position)?.statusViewData ?: return + viewModel.accept(StatusAction.Bookmark(bookmark, statusViewData)) + } + + override fun onVoteInPoll(position: Int, choices: List) { + val statusViewData = adapter.peek(position)?.statusViewData ?: return + val poll = statusViewData.status.poll ?: return + viewModel.accept(StatusAction.VoteInPoll(poll, choices, statusViewData)) + } + + override fun onMore(view: View, position: Int) { + val status = adapter.peek(position)?.statusViewData?.status ?: return + super.more(status, view, position) + } + + override fun onViewMedia(position: Int, attachmentIndex: Int, view: View?) { + val status = adapter.peek(position)?.statusViewData?.status ?: return + super.viewMedia(attachmentIndex, list(status), view) + } + + override fun onViewThread(position: Int) { + val status = adapter.peek(position)?.statusViewData?.status ?: return + super.viewThread(status.actionableId, status.actionableStatus.url) + } + + override fun onOpenReblog(position: Int) { + val account = adapter.peek(position)?.account!! + onViewAccount(account.id) + } + + override fun onExpandedChange(expanded: Boolean, position: Int) { + val notificationViewData = adapter.snapshot()[position] ?: return + notificationViewData.statusViewData = notificationViewData.statusViewData?.copy( + isExpanded = expanded + ) + adapter.notifyItemChanged(position) + } + + override fun onContentHiddenChange(isShowing: Boolean, position: Int) { + val notificationViewData = adapter.snapshot()[position] ?: return + notificationViewData.statusViewData = notificationViewData.statusViewData?.copy( + isShowingContent = isShowing + ) + adapter.notifyItemChanged(position) + } + + override fun onLoadMore(position: Int) { + // Empty -- this fragment doesn't show placeholders + } + + override fun onContentCollapsedChange(isCollapsed: Boolean, position: Int) { + val notificationViewData = adapter.snapshot()[position] ?: return + notificationViewData.statusViewData = notificationViewData.statusViewData?.copy( + isCollapsed = isCollapsed + ) + adapter.notifyItemChanged(position) + } + + override fun onNotificationContentCollapsedChange(isCollapsed: Boolean, position: Int) { + onContentCollapsedChange(isCollapsed, position) + } + + private fun clearNotifications() { + binding.swipeRefreshLayout.isRefreshing = false + binding.progressBar.isVisible = false + viewModel.accept(FallibleUiAction.ClearNotifications) + } + + private fun showFilterDialog() { + FilterDialogFragment(viewModel.uiState.value.activeFilter) { filter -> + if (viewModel.uiState.value.activeFilter != filter) { + viewModel.accept(InfallibleUiAction.ApplyFilter(filter)) + } + } + .show(parentFragmentManager, "dialogFilter") + } + + override fun onViewTag(tag: String) { + super.viewTag(tag) + } + + override fun onViewAccount(id: String) { + super.viewAccount(id) + } + + override fun onMute(mute: Boolean, id: String, position: Int, notifications: Boolean) { + adapter.refresh() + } + + override fun onBlock(block: Boolean, id: String, position: Int) { + adapter.refresh() + } + + override fun onRespondToFollowRequest(accept: Boolean, accountId: String, position: Int) { + if (accept) { + viewModel.accept(NotificationAction.AcceptFollowRequest(accountId)) + } else { + viewModel.accept(NotificationAction.RejectFollowRequest(accountId)) + } + } + + override fun onViewThreadForStatus(status: Status) { + super.viewThread(status.actionableId, status.actionableStatus.url) + } + + override fun onViewReport(reportId: String) { + requireContext().openLink( + "https://${accountManager.activeAccount!!.domain}/admin/reports/$reportId" + ) + } + + public override fun removeItem(position: Int) { + // Empty -- this fragment doesn't remove items + } + + override fun onReselect() { + if (isAdded) { + binding.appBarOptions.setExpanded(true, false) + layoutManager.scrollToPosition(0) + } + } + + companion object { + private const val TAG = "NotificationF" + fun newInstance() = NotificationsFragment() + + private val notificationDiffCallback: DiffUtil.ItemCallback = + object : DiffUtil.ItemCallback() { + override fun areItemsTheSame( + oldItem: NotificationViewData, + newItem: NotificationViewData + ): Boolean { + return oldItem.id == newItem.id + } + + override fun areContentsTheSame( + oldItem: NotificationViewData, + newItem: NotificationViewData + ): Boolean { + return false + } + + 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 a whole view holder + null + } + } + } + } +} + +class FilterDialogFragment( + private val activeFilter: Set, + private val listener: ((filter: Set) -> Unit) +) : DialogFragment() { + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + val context = requireContext() + + val items = Notification.Type.visibleTypes.map { getString(it.uiString) }.toTypedArray() + val checkedItems = Notification.Type.visibleTypes.map { + !activeFilter.contains(it) + }.toBooleanArray() + + val builder = AlertDialog.Builder(context) + .setTitle(R.string.notifications_apply_filter) + .setMultiChoiceItems(items, checkedItems) { _, which, isChecked -> + checkedItems[which] = isChecked + } + .setPositiveButton(android.R.string.ok) { _, _ -> + val excludes: MutableSet = HashSet() + for (i in Notification.Type.visibleTypes.indices) { + if (!checkedItems[i]) excludes.add(Notification.Type.visibleTypes[i]) + } + listener(excludes) + } + .setNegativeButton(android.R.string.cancel) { _, _ -> } + return builder.create() + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsLoadStateAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsLoadStateAdapter.kt new file mode 100644 index 000000000..0a281ccd9 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsLoadStateAdapter.kt @@ -0,0 +1,38 @@ +/* + * 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 . + */ + +package com.keylesspalace.tusky.components.notifications + +import android.view.ViewGroup +import androidx.paging.LoadState +import androidx.paging.LoadStateAdapter + +/** Show load state and retry options when loading notifications */ +class NotificationsLoadStateAdapter( + private val retry: () -> Unit +) : LoadStateAdapter() { + override fun onCreateViewHolder( + parent: ViewGroup, + loadState: LoadState + ): NotificationsLoadStateViewHolder { + return NotificationsLoadStateViewHolder.create(parent, retry) + } + + override fun onBindViewHolder(holder: NotificationsLoadStateViewHolder, loadState: LoadState) { + holder.bind(loadState) + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsLoadStateViewHolder.kt b/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsLoadStateViewHolder.kt new file mode 100644 index 000000000..f3c006d32 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsLoadStateViewHolder.kt @@ -0,0 +1,73 @@ +/* + * 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 . + */ + +package com.keylesspalace.tusky.components.notifications + +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.core.view.isVisible +import androidx.paging.LoadState +import androidx.recyclerview.widget.RecyclerView +import com.keylesspalace.tusky.R +import com.keylesspalace.tusky.databinding.ItemNotificationsLoadStateFooterViewBinding +import java.net.SocketTimeoutException + +/** + * Display the header/footer loading state to the user. + * + * Either: + * + * 1. A page is being loaded, display a progress view, or + * 2. An error occurred, display an error message with a "retry" button + * + * @param retry function to invoke if the user clicks the "retry" button + */ +class NotificationsLoadStateViewHolder( + private val binding: ItemNotificationsLoadStateFooterViewBinding, + retry: () -> Unit +) : RecyclerView.ViewHolder(binding.root) { + init { + binding.retryButton.setOnClickListener { retry.invoke() } + } + + fun bind(loadState: LoadState) { + if (loadState is LoadState.Error) { + val ctx = binding.root.context + binding.errorMsg.text = when (loadState.error) { + is SocketTimeoutException -> ctx.getString(R.string.socket_timeout_exception) + // Other exceptions to consider: + // - UnknownHostException, default text is: + // Unable to resolve "%s": No address associated with hostname + else -> loadState.error.localizedMessage + } + } + binding.progressBar.isVisible = loadState is LoadState.Loading + binding.retryButton.isVisible = loadState is LoadState.Error + binding.errorMsg.isVisible = loadState is LoadState.Error + } + + companion object { + fun create(parent: ViewGroup, retry: () -> Unit): NotificationsLoadStateViewHolder { + val binding = ItemNotificationsLoadStateFooterViewBinding.inflate( + LayoutInflater.from(parent.context), + parent, + false + ) + return NotificationsLoadStateViewHolder(binding, retry) + } + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsPagingAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsPagingAdapter.kt new file mode 100644 index 000000000..067778e20 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsPagingAdapter.kt @@ -0,0 +1,209 @@ +/* + * 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 . + */ + +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.ReportNotificationViewHolder +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.SimpleListItem1Binding +import com.keylesspalace.tusky.entity.Notification +import com.keylesspalace.tusky.entity.Status +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 + +/** How to present the notification in the UI */ +enum class NotificationViewKind { + /** View as the original status */ + STATUS, + + /** View as the original status, with the interaction type above */ + NOTIFICATION, + FOLLOW, + FOLLOW_REQUEST, + REPORT, + UNKNOWN; + + companion object { + fun from(kind: Notification.Type?): NotificationViewKind { + return when (kind) { + Notification.Type.MENTION, + Notification.Type.POLL, + Notification.Type.UNKNOWN -> STATUS + Notification.Type.FAVOURITE, + Notification.Type.REBLOG, + Notification.Type.STATUS, + Notification.Type.UPDATE -> NOTIFICATION + Notification.Type.FOLLOW, + Notification.Type.SIGN_UP -> FOLLOW + Notification.Type.FOLLOW_REQUEST -> FOLLOW_REQUEST + Notification.Type.REPORT -> REPORT + null -> UNKNOWN + } + } + } +} + +interface NotificationActionListener { + fun onViewAccount(id: String) + fun onViewThreadForStatus(status: Status) + fun onViewReport(reportId: String) + + /** + * Called when the status has a content warning and the visibility of the content behind + * the warning is being changed. + * + * @param expanded the desired state of the content behind the content warning + * @param position the adapter position of the view + * + */ + fun onExpandedChange(expanded: Boolean, position: Int) + + /** + * Called when the status [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. + */ + fun onNotificationContentCollapsedChange(isCollapsed: Boolean, position: Int) +} + +class NotificationsPagingAdapter( + diffCallback: DiffUtil.ItemCallback, + /** ID of the the account that notifications are being displayed for */ + private val accountId: String, + private val statusActionListener: StatusActionListener, + private val notificationActionListener: NotificationActionListener, + private val accountActionListener: AccountActionListener, + var statusDisplayOptions: StatusDisplayOptions +) : PagingDataAdapter(diffCallback) { + + private val absoluteTimeFormatter = AbsoluteTimeFormatter() + + /** View holders in this adapter must implement this interface */ + interface ViewHolder { + /** Bind the data from the notification and payloads to the view */ + fun bind( + viewData: NotificationViewData, + payloads: List<*>?, + statusDisplayOptions: StatusDisplayOptions + ) + } + + init { + stateRestorationPolicy = StateRestorationPolicy.PREVENT_WHEN_EMPTY + } + + override fun getItemViewType(position: Int): Int { + return NotificationViewKind.from(getItem(position)?.type).ordinal + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { + val inflater = LayoutInflater.from(parent.context) + + return when (NotificationViewKind.values()[viewType]) { + NotificationViewKind.STATUS -> { + StatusViewHolder( + ItemStatusBinding.inflate(inflater, parent, false), + statusActionListener, + accountId + ) + } + NotificationViewKind.NOTIFICATION -> { + StatusNotificationViewHolder( + ItemStatusNotificationBinding.inflate(inflater, parent, false), + statusActionListener, + notificationActionListener, + absoluteTimeFormatter + ) + } + NotificationViewKind.FOLLOW -> { + FollowViewHolder( + ItemFollowBinding.inflate(inflater, parent, false), + notificationActionListener + ) + } + NotificationViewKind.FOLLOW_REQUEST -> { + FollowRequestViewHolder( + ItemFollowRequestBinding.inflate(inflater, parent, false), + accountActionListener, + showHeader = true + ) + } + NotificationViewKind.REPORT -> { + ReportNotificationViewHolder( + ItemReportNotificationBinding.inflate(inflater, parent, false), + notificationActionListener + ) + } + else -> { + FallbackNotificationViewHolder( + SimpleListItem1Binding.inflate(inflater, parent, false) + ) + } + } + } + + override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { + bindViewHolder(holder, position, null) + } + + override fun onBindViewHolder( + holder: RecyclerView.ViewHolder, + position: Int, + payloads: MutableList + ) { + bindViewHolder(holder, position, payloads) + } + + private fun bindViewHolder( + holder: RecyclerView.ViewHolder, + position: Int, + payloads: List<*>? + ) { + getItem(position)?.let { (holder as ViewHolder).bind(it, payloads, statusDisplayOptions) } + } + + /** + * Notification view holder to use if no other type is appropriate. Should never normally + * be used, but is useful when migrating code. + */ + private class FallbackNotificationViewHolder( + val binding: SimpleListItem1Binding + ) : ViewHolder, RecyclerView.ViewHolder(binding.root) { + override fun bind( + viewData: NotificationViewData, + payloads: List<*>?, + statusDisplayOptions: StatusDisplayOptions + ) { + binding.text1.text = viewData.statusViewData?.content + } + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsPagingSource.kt b/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsPagingSource.kt new file mode 100644 index 000000000..44db2308b --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsPagingSource.kt @@ -0,0 +1,184 @@ +/* + * 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 . + */ + +package com.keylesspalace.tusky.components.notifications + +import android.util.Log +import androidx.paging.PagingSource +import androidx.paging.PagingState +import com.keylesspalace.tusky.entity.Notification +import com.keylesspalace.tusky.network.MastodonApi +import com.keylesspalace.tusky.util.HttpHeaderLink +import kotlinx.coroutines.async +import kotlinx.coroutines.coroutineScope +import okhttp3.Headers +import retrofit2.Response +import javax.inject.Inject + +/** Models next/prev links from the "Links" header in an API response */ +data class Links(val next: String?, val prev: String?) + +/** [PagingSource] for Mastodon Notifications, identified by the Notification ID */ +class NotificationsPagingSource @Inject constructor( + private val mastodonApi: MastodonApi, + private val notificationFilter: Set +) : PagingSource() { + override suspend fun load(params: LoadParams): LoadResult { + Log.d(TAG, "load() with ${params.javaClass.simpleName} for key: ${params.key}") + + try { + val response = when (params) { + is LoadParams.Refresh -> { + getInitialPage(params) + } + is LoadParams.Append -> mastodonApi.notifications( + maxId = params.key, + limit = params.loadSize, + excludes = notificationFilter + ) + is LoadParams.Prepend -> mastodonApi.notifications( + minId = params.key, + limit = params.loadSize, + excludes = notificationFilter + ) + } + + if (!response.isSuccessful) { + return LoadResult.Error(Throwable(response.errorBody().toString())) + } + + val links = getPageLinks(response.headers()["link"]) + return LoadResult.Page( + data = response.body()!!, + nextKey = links.next, + prevKey = links.prev + ) + } catch (e: Exception) { + return LoadResult.Error(e) + } + } + + /** + * Fetch the initial page of notifications, using params.key as the ID of the initial + * notification to fetch. + * + * - If there is no key, a page of the most recent notifications is returned + * - If the notification exists, and is not filtered, a page of notifications is returned + * - If the notification does not exist, or is filtered, the page of notifications immediately + * before is returned + * - If there is no page of notifications immediately before then the page immediately after + * is returned + */ + private suspend fun getInitialPage(params: LoadParams): Response> = coroutineScope { + // If the key is null this is straightforward, just return the most recent notifications. + val key = params.key + ?: return@coroutineScope mastodonApi.notifications( + limit = params.loadSize, + excludes = notificationFilter + ) + + // It's important to return *something* from this state. If an empty page is returned + // (even with next/prev links) Pager3 assumes there is no more data to load and stops. + // + // In addition, the Mastodon API does not let you fetch a page that contains a given key. + // You can fetch the page immediately before the key, or the page immediately after, but + // you can not fetch the page itself. + + // First, try and get the notification itself, and the notifications immediately before + // it. This is so that a full page of results can be returned. Returning just the + // single notification means the displayed list can jump around a bit as more data is + // loaded. + // + // Make both requests, and wait for the first to complete. + val deferredNotification = async { mastodonApi.notification(id = key) } + val deferredNotificationPage = async { + mastodonApi.notifications(maxId = key, limit = params.loadSize, excludes = notificationFilter) + } + + val notification = deferredNotification.await() + if (notification.isSuccessful) { + // If this was successful we must still check that the user is not filtering this type + // of notification, as fetching a single notification ignores filters. Returning this + // notification if the user is filtering the type is wrong. + notification.body()?.let { body -> + if (!notificationFilter.contains(body.type)) { + // Notification is *not* filtered. We can return this, but need the next page of + // notifications as well + + // Collect all notifications in to this list + val notifications = mutableListOf(body) + val notificationPage = deferredNotificationPage.await() + if (notificationPage.isSuccessful) { + notificationPage.body()?.let { + notifications.addAll(it) + } + } + + // "notifications" now contains at least one notification we can return, and + // hopefully a full page. + + // Build correct max_id and min_id links for the response. The "min_id" to use + // when fetching the next page is the same as "key". The "max_id" is the ID of + // the oldest notification in the list. + val maxId = notifications.last().id + val headers = Headers.Builder() + .add("link: ; rel=\"next\", ; rel=\"prev\"") + .build() + + return@coroutineScope Response.success(notifications, headers) + } + } + } + + // The user's last read notification was missing or is filtered. Use the page of + // notifications chronologically older than their desired notification. + deferredNotificationPage.await().apply { + if (this.isSuccessful) return@coroutineScope this + } + + // There were no notifications older than the user's desired notification. Return the page + // of notifications immediately newer than their desired notification. + return@coroutineScope mastodonApi.notifications( + minId = key, + limit = params.loadSize, + excludes = notificationFilter + ) + } + + private fun getPageLinks(linkHeader: String?): Links { + val links = HttpHeaderLink.parse(linkHeader) + return Links( + next = HttpHeaderLink.findByRelationType(links, "next")?.uri?.getQueryParameter( + "max_id" + ), + prev = HttpHeaderLink.findByRelationType(links, "prev")?.uri?.getQueryParameter( + "min_id" + ) + ) + } + + override fun getRefreshKey(state: PagingState): String? { + return state.anchorPosition?.let { anchorPosition -> + val anchorPage = state.closestPageToPosition(anchorPosition) + anchorPage?.prevKey ?: anchorPage?.nextKey + } + } + + companion object { + private const val TAG = "NotificationsPagingSource" + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsRepository.kt b/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsRepository.kt new file mode 100644 index 000000000..25c8458ac --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsRepository.kt @@ -0,0 +1,74 @@ +/* + * 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 . + */ + +package com.keylesspalace.tusky.components.notifications + +import android.util.Log +import androidx.paging.InvalidatingPagingSourceFactory +import androidx.paging.Pager +import androidx.paging.PagingConfig +import androidx.paging.PagingData +import androidx.paging.PagingSource +import com.keylesspalace.tusky.entity.Notification +import com.keylesspalace.tusky.network.MastodonApi +import kotlinx.coroutines.flow.Flow +import okhttp3.ResponseBody +import retrofit2.Response +import javax.inject.Inject + +class NotificationsRepository @Inject constructor( + private val mastodonApi: MastodonApi +) { + private var factory: InvalidatingPagingSourceFactory? = null + + /** + * @return flow of Mastodon [Notification], excluding all types in [filter]. + * Notifications are loaded in [pageSize] increments. + */ + fun getNotificationsStream( + filter: Set, + pageSize: Int = PAGE_SIZE, + initialKey: String? = null + ): Flow> { + Log.d(TAG, "getNotificationsStream(), filtering: $filter") + + factory = InvalidatingPagingSourceFactory { + NotificationsPagingSource(mastodonApi, filter) + } + + return Pager( + config = PagingConfig(pageSize = pageSize), + initialKey = initialKey, + pagingSourceFactory = factory!! + ).flow + } + + /** Invalidate the active paging source, see [PagingSource.invalidate] */ + fun invalidate() { + factory?.invalidate() + } + + /** Clear notifications */ + suspend fun clearNotifications(): Response { + return mastodonApi.clearNotifications() + } + + companion object { + private const val TAG = "NotificationsRepository" + private const val PAGE_SIZE = 30 + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsViewModel.kt new file mode 100644 index 000000000..1c84dcadf --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsViewModel.kt @@ -0,0 +1,522 @@ +/* + * 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 . + */ + +package com.keylesspalace.tusky.components.notifications + +import android.content.SharedPreferences +import android.util.Log +import androidx.annotation.StringRes +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import androidx.paging.PagingData +import androidx.paging.cachedIn +import androidx.paging.map +import com.keylesspalace.tusky.R +import com.keylesspalace.tusky.appstore.BlockEvent +import com.keylesspalace.tusky.appstore.EventHub +import com.keylesspalace.tusky.appstore.MuteConversationEvent +import com.keylesspalace.tusky.appstore.MuteEvent +import com.keylesspalace.tusky.appstore.PreferenceChangedEvent +import com.keylesspalace.tusky.components.timeline.util.ifExpected +import com.keylesspalace.tusky.db.AccountManager +import com.keylesspalace.tusky.entity.Notification +import com.keylesspalace.tusky.entity.Poll +import com.keylesspalace.tusky.settings.PrefKeys +import com.keylesspalace.tusky.usecase.TimelineCases +import com.keylesspalace.tusky.util.StatusDisplayOptions +import com.keylesspalace.tusky.util.deserialize +import com.keylesspalace.tusky.util.serialize +import com.keylesspalace.tusky.util.toViewData +import com.keylesspalace.tusky.viewdata.NotificationViewData +import com.keylesspalace.tusky.viewdata.StatusViewData +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import kotlinx.coroutines.rx3.asFlow +import kotlinx.coroutines.rx3.await +import retrofit2.HttpException +import javax.inject.Inject + +data class UiState( + /** Filtered notification types */ + val activeFilter: Set = emptySet(), + + /** True if the UI to filter and clear notifications should be shown */ + val showFilterOptions: Boolean = false, + + /** True if the FAB should be shown while scrolling */ + val showFabWhileScrolling: Boolean = true +) + +/** Preferences the UI reacts to */ +data class UiPrefs( + val showFabWhileScrolling: Boolean, + val showFilter: Boolean +) { + companion object { + /** Relevant preference keys. Changes to any of these trigger a display update */ + val prefKeys = setOf( + PrefKeys.FAB_HIDE, + PrefKeys.SHOW_NOTIFICATIONS_FILTER + ) + } +} + +/** Parent class for all UI actions, fallible or infallible. */ +sealed class UiAction + +/** Actions the user can trigger from the UI. These actions may fail. */ +sealed class FallibleUiAction : UiAction() { + /** Clear all notifications */ + object ClearNotifications : FallibleUiAction() +} + +/** + * Actions the user can trigger from the UI that either cannot fail, or if they do fail, + * do not show an error. + */ +sealed class InfallibleUiAction : UiAction() { + /** Apply a new filter to the notification list */ + // This saves the list to the local database, which triggers a refresh of the data. + // Saving the data can't fail, which is why this is infallible. Refreshing the + // data may fail, but that's handled by the paging system / adapter refresh logic. + data class ApplyFilter(val filter: Set) : InfallibleUiAction() + + /** + * User is leaving the fragment, save the ID of the visible notification. + * + * Infallible because if it fails there's nowhere to show the error, and nothing the user + * can do. + */ + data class SaveVisibleId(val visibleId: String) : InfallibleUiAction() +} + +/** Actions the user can trigger on an individual notification. These may fail. */ +sealed class NotificationAction : FallibleUiAction() { + data class AcceptFollowRequest(val accountId: String) : NotificationAction() + + data class RejectFollowRequest(val accountId: String) : NotificationAction() +} + +sealed class UiSuccess { + // These three are from menu items on the status. Currently they don't come to the + // viewModel as actions, they're noticed when events are posted. That will change, + // but for the moment we can still report them to the UI. Typically, receiving any + // of these three should trigger the UI to refresh. + + /** A user was blocked */ + object Block : UiSuccess() + + /** A user was muted */ + object Mute : UiSuccess() + + /** A conversation was muted */ + object MuteConversation : UiSuccess() +} + +/** The result of a successful action on a notification */ +sealed class NotificationActionSuccess( + /** String resource with an error message to show the user */ + @StringRes val msg: Int, + + /** + * The original action, in case additional information is required from it to display the + * message. + */ + open val action: NotificationAction +) : UiSuccess() { + data class AcceptFollowRequest(override val action: NotificationAction) : + NotificationActionSuccess(R.string.ui_success_accepted_follow_request, action) + data class RejectFollowRequest(override val action: NotificationAction) : + NotificationActionSuccess(R.string.ui_success_rejected_follow_request, action) + + companion object { + fun from(action: NotificationAction) = when (action) { + is NotificationAction.AcceptFollowRequest -> AcceptFollowRequest(action) + is NotificationAction.RejectFollowRequest -> RejectFollowRequest(action) + } + } +} + +/** Actions the user can trigger on an individual status */ +sealed class StatusAction( + open val statusViewData: StatusViewData.Concrete +) : FallibleUiAction() { + /** Set the bookmark state for a status */ + data class Bookmark(val state: Boolean, override val statusViewData: StatusViewData.Concrete) : + StatusAction(statusViewData) + + /** Set the favourite state for a status */ + data class Favourite(val state: Boolean, override val statusViewData: StatusViewData.Concrete) : + StatusAction(statusViewData) + + /** Set the reblog state for a status */ + data class Reblog(val state: Boolean, override val statusViewData: StatusViewData.Concrete) : + StatusAction(statusViewData) + + /** Vote in a poll */ + data class VoteInPoll( + val poll: Poll, + val choices: List, + override val statusViewData: StatusViewData.Concrete + ) : StatusAction(statusViewData) +} + +/** Changes to a status' visible state after API calls */ +sealed class StatusActionSuccess(open val action: StatusAction) : UiSuccess() { + data class Bookmark(override val action: StatusAction.Bookmark) : + StatusActionSuccess(action) + + data class Favourite(override val action: StatusAction.Favourite) : + StatusActionSuccess(action) + + data class Reblog(override val action: StatusAction.Reblog) : + StatusActionSuccess(action) + + data class VoteInPoll(override val action: StatusAction.VoteInPoll) : + StatusActionSuccess(action) + + companion object { + fun from(action: StatusAction) = when (action) { + is StatusAction.Bookmark -> Bookmark(action) + is StatusAction.Favourite -> Favourite(action) + is StatusAction.Reblog -> Reblog(action) + is StatusAction.VoteInPoll -> VoteInPoll(action) + } + } +} + +/** Errors from fallible view model actions that the UI will need to show */ +sealed class UiError( + /** The exception associated with the error */ + open val exception: Exception, + + /** String resource with an error message to show the user */ + @StringRes val message: Int, + + /** The action that failed. Can be resent to retry the action */ + open val action: UiAction? = null +) { + data class ClearNotifications(override val exception: Exception) : UiError( + exception, + R.string.ui_error_clear_notifications + ) + + data class Bookmark( + override val exception: Exception, + override val action: StatusAction.Bookmark + ) : UiError(exception, R.string.ui_error_bookmark, action) + + data class Favourite( + override val exception: Exception, + override val action: StatusAction.Favourite + ) : UiError(exception, R.string.ui_error_favourite, action) + + data class Reblog( + override val exception: Exception, + override val action: StatusAction.Reblog + ) : UiError(exception, R.string.ui_error_reblog, action) + + data class VoteInPoll( + override val exception: Exception, + override val action: StatusAction.VoteInPoll + ) : UiError(exception, R.string.ui_error_vote, action) + + data class AcceptFollowRequest( + override val exception: Exception, + override val action: NotificationAction.AcceptFollowRequest + ) : UiError(exception, R.string.ui_error_accept_follow_request, action) + + data class RejectFollowRequest( + override val exception: Exception, + override val action: NotificationAction.RejectFollowRequest + ) : UiError(exception, R.string.ui_error_reject_follow_request, action) + + companion object { + fun make(exception: Exception, action: FallibleUiAction) = when (action) { + is StatusAction.Bookmark -> Bookmark(exception, action) + is StatusAction.Favourite -> Favourite(exception, action) + is StatusAction.Reblog -> Reblog(exception, action) + is StatusAction.VoteInPoll -> VoteInPoll(exception, action) + is NotificationAction.AcceptFollowRequest -> AcceptFollowRequest(exception, action) + is NotificationAction.RejectFollowRequest -> RejectFollowRequest(exception, action) + FallibleUiAction.ClearNotifications -> ClearNotifications(exception) + } + } +} + +@OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class) +class NotificationsViewModel @Inject constructor( + private val repository: NotificationsRepository, + private val preferences: SharedPreferences, + private val accountManager: AccountManager, + private val timelineCases: TimelineCases, + private val eventHub: EventHub +) : ViewModel() { + + val uiState: StateFlow + + /** Flow of changes to statusDisplayOptions, for use by the UI */ + val statusDisplayOptions: StateFlow + + val pagingData: Flow> + + /** Flow of user actions received from the UI */ + private val uiAction = MutableSharedFlow() + + /** Flow of successful action results */ + // Note: These are a SharedFlow instead of a StateFlow because success or error state does not + // need to be retained. A message is shown once to a user and then dismissed. Re-collecting the + // flow (e.g., after a device orientation change) should not re-show the most recent success or + // error message, as it will be confusing to the user. + val uiSuccess = MutableSharedFlow() + + /** Flow of transient errors for the UI to present */ + val uiError = MutableSharedFlow() + + /** Accept UI actions in to actionStateFlow */ + val accept: (UiAction) -> Unit = { action -> + viewModelScope.launch { uiAction.emit(action) } + } + + init { + // Handle changes to notification filters + val notificationFilter = uiAction + .filterIsInstance() + .distinctUntilChanged() + // Save each change back to the active account + .onEach { action -> + Log.d(TAG, "notificationFilter: $action") + accountManager.activeAccount?.let { account -> + account.notificationsFilter = serialize(action.filter) + accountManager.saveAccount(account) + } + } + // Load the initial filter from the active account + .onStart { + emit( + InfallibleUiAction.ApplyFilter( + filter = deserialize(accountManager.activeAccount?.notificationsFilter) + ) + ) + } + + // Save the visible notification ID + viewModelScope.launch { + uiAction + .filterIsInstance() + .distinctUntilChanged() + .collectLatest { action -> + Log.d(TAG, "Saving visible ID: ${action.visibleId}") + accountManager.activeAccount?.let { account -> + account.lastNotificationId = action.visibleId + accountManager.saveAccount(account) + } + } + } + + // Set initial status display options from the user's preferences. + // + // Then collect future preference changes and emit new values in to + // statusDisplayOptions if necessary. + statusDisplayOptions = MutableStateFlow( + StatusDisplayOptions.from( + preferences, + accountManager.activeAccount!! + ) + ) + + viewModelScope.launch { + eventHub.events.asFlow() + .filterIsInstance() + .filter { StatusDisplayOptions.prefKeys.contains(it.preferenceKey) } + .map { + statusDisplayOptions.value.make( + preferences, + it.preferenceKey, + accountManager.activeAccount!! + ) + } + .collect { + statusDisplayOptions.emit(it) + } + } + + // Handle UiAction.ClearNotifications + viewModelScope.launch { + uiAction.filterIsInstance() + .collectLatest { + try { + repository.clearNotifications().apply { + if (this.isSuccessful) { + repository.invalidate() + } else { + uiError.emit(UiError.make(HttpException(this), it)) + } + } + } catch (e: Exception) { + ifExpected(e) { uiError.emit(UiError.make(e, it)) } + } + } + } + + // Handle NotificationAction.* + viewModelScope.launch { + uiAction.filterIsInstance() + .debounce(DEBOUNCE_TIMEOUT_MS) + .collect { action -> + try { + when (action) { + is NotificationAction.AcceptFollowRequest -> + timelineCases.acceptFollowRequest(action.accountId).await() + is NotificationAction.RejectFollowRequest -> + timelineCases.rejectFollowRequest(action.accountId).await() + } + uiSuccess.emit(NotificationActionSuccess.from(action)) + } catch (e: Exception) { + ifExpected(e) { uiError.emit(UiError.make(e, action)) } + } + } + } + + // Handle StatusAction.* + viewModelScope.launch { + uiAction.filterIsInstance() + .debounce(DEBOUNCE_TIMEOUT_MS) // avoid double-taps + .collect { action -> + try { + when (action) { + is StatusAction.Bookmark -> + timelineCases.bookmark( + action.statusViewData.actionableId, + action.state + ).await() + is StatusAction.Favourite -> + timelineCases.favourite( + action.statusViewData.actionableId, + action.state + ).await() + is StatusAction.Reblog -> + timelineCases.reblog( + action.statusViewData.actionableId, + action.state + ).await() + is StatusAction.VoteInPoll -> + timelineCases.voteInPoll( + action.statusViewData.actionableId, + action.poll.id, + action.choices + ).await() + } + uiSuccess.emit(StatusActionSuccess.from(action)) + } catch (e: Exception) { + ifExpected(e) { uiError.emit(UiError.make(e, action)) } + } + } + } + + // Handle events that should refresh the list + viewModelScope.launch { + eventHub.events.asFlow().collectLatest { + when (it) { + is BlockEvent -> uiSuccess.emit(UiSuccess.Block) + is MuteEvent -> uiSuccess.emit(UiSuccess.Mute) + is MuteConversationEvent -> uiSuccess.emit(UiSuccess.MuteConversation) + } + } + } + + // The database stores "0" as the last notification ID if notifications have not been + // fetched. Convert to null to ensure a full fetch in this case + val lastNotificationId = when (val id = accountManager.activeAccount?.lastNotificationId) { + "0" -> null + else -> id + } + Log.d(TAG, "Restoring at $lastNotificationId") + + pagingData = notificationFilter + .flatMapLatest { action -> + getNotifications(filters = action.filter, initialKey = lastNotificationId) + } + .cachedIn(viewModelScope) + + uiState = combine(notificationFilter, getUiPrefs()) { filter, prefs -> + UiState( + activeFilter = filter.filter, + showFilterOptions = prefs.showFilter, + showFabWhileScrolling = prefs.showFabWhileScrolling + ) + }.stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(stopTimeoutMillis = 5000), + initialValue = UiState() + ) + } + + private fun getNotifications( + filters: Set, + initialKey: String? = null + ): Flow> { + return repository.getNotificationsStream(filter = filters, initialKey = initialKey) + .map { pagingData -> + pagingData.map { notification -> + notification.toViewData( + isShowingContent = statusDisplayOptions.value.showSensitiveMedia || + !(notification.status?.actionableStatus?.sensitive ?: false), + isExpanded = statusDisplayOptions.value.openSpoiler, + isCollapsed = true + ) + } + } + } + + /** + * @return Flow of relevant preferences that change the UI + */ + // TODO: Preferences should be in a repository + private fun getUiPrefs() = eventHub.events.asFlow() + .filterIsInstance() + .filter { UiPrefs.prefKeys.contains(it.preferenceKey) } + .map { toPrefs() } + .onStart { emit(toPrefs()) } + + private fun toPrefs() = UiPrefs( + showFabWhileScrolling = !preferences.getBoolean(PrefKeys.FAB_HIDE, false), + showFilter = preferences.getBoolean(PrefKeys.SHOW_NOTIFICATIONS_FILTER, true) + ) + + companion object { + private const val TAG = "NotificationsViewModel" + private const val DEBOUNCE_TIMEOUT_MS = 500L + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/notifications/PushNotificationHelper.kt b/app/src/main/java/com/keylesspalace/tusky/components/notifications/PushNotificationHelper.kt index cf1dd4384..6745579ed 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/notifications/PushNotificationHelper.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/notifications/PushNotificationHelper.kt @@ -150,7 +150,7 @@ fun disableAllNotifications(context: Context, accountManager: AccountManager) { private fun buildSubscriptionData(context: Context, account: AccountEntity): Map = buildMap { - Notification.Type.asList.forEach { + Notification.Type.visibleTypes.forEach { put("data[alerts][${it.presentation}]", NotificationHelper.filterNotification(account, it, context)) } } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/notifications/StatusNotificationViewHolder.kt b/app/src/main/java/com/keylesspalace/tusky/components/notifications/StatusNotificationViewHolder.kt new file mode 100644 index 000000000..402f38725 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/notifications/StatusNotificationViewHolder.kt @@ -0,0 +1,385 @@ +/* + * 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 . + */ + +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.SpannableStringBuilder +import android.text.Spanned +import android.text.TextUtils +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.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.viewdata.NotificationViewData +import com.keylesspalace.tusky.viewdata.StatusViewData +import java.util.Date + +/** + * View holder for a status with an activity to be notified about (posted, boosted, + * favourited, or edited, per [NotificationViewKind.from]). + * + * Shows a line with the activity, and who initiated the activity. Clicking this should + * go to the profile page for the initiator. + * + * Displays the original status below that. Clicking this should go to the original + * status in context. + */ +internal class StatusNotificationViewHolder( + private val binding: ItemStatusNotificationBinding, + private val statusActionListener: StatusActionListener, + private val notificationActionListener: NotificationActionListener, + private val absoluteTimeFormatter: AbsoluteTimeFormatter +) : NotificationsPagingAdapter.ViewHolder, 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, + payloads: List<*>?, + statusDisplayOptions: StatusDisplayOptions + ) { + val statusViewData = viewData.statusViewData + if (payloads.isNullOrEmpty()) { + // Hide null statuses. Shouldn't happen according to the spec, but some servers + // have been seen to do this (https://github.com/tuskyapp/Tusky/issues/2252) + 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 { + notificationActionListener.onViewThreadForStatus(statusViewData.status) + } + binding.notificationContent.setOnClickListener { + notificationActionListener.onViewThreadForStatus(statusViewData.status) + } + binding.notificationTopText.setOnClickListener { + notificationActionListener.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.statusNameBar.visibility = if (show) View.VISIBLE else View.GONE + binding.notificationContentWarningDescription.visibility = + if (show) View.VISIBLE else View.GONE + binding.notificationContentWarningButton.visibility = + if (show) View.VISIBLE else View.GONE + binding.notificationContent.visibility = if (show) View.VISIBLE else View.GONE + binding.notificationStatusAvatar.visibility = if (show) View.VISIBLE else View.GONE + binding.notificationNotificationAvatar.visibility = if (show) View.VISIBLE else View.GONE + } + + private fun setDisplayName(name: String, emojis: List?, 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 { + // This is the visible timestampInfo. + val readout: String + /* This one is for screen-readers. Frequently, they would mispronounce timestamps like "17m" + * as 17 meters instead of minutes. */ + val readoutAloud: CharSequence + if (createdAt != null) { + val then = createdAt.time + val now = Date().time + readout = getRelativeTimeSpanString(binding.statusMetaInfo.context, then, now) + readoutAloud = DateUtils.getRelativeTimeSpanString( + then, + now, + DateUtils.SECOND_IN_MILLIS, + DateUtils.FORMAT_ABBREV_RELATIVE + ) + } else { + // unknown minutes~ + readout = "?m" + readoutAloud = "? minutes" + } + 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, + 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) + val str = SpannableStringBuilder(wholeMessage) + val displayNameIndex = format.indexOf("%s") + str.setSpan( + StyleSpan(Typeface.BOLD), + displayNameIndex, + displayNameIndex + displayName.length, + Spanned.SPAN_EXCLUSIVE_EXCLUSIVE + ) + val emojifiedText = str.emojify( + notificationViewData.account.emojis, + binding.notificationTopText, + animateEmojis + ) + binding.notificationTopText.text = emojifiedText + if (statusViewData != null) { + val hasSpoiler = !TextUtils.isEmpty(statusViewData.status.spoilerText) + 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) { + notificationActionListener.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 = !TextUtils.isEmpty(statusViewData.status.spoilerText) + 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) { + notificationActionListener.onNotificationContentCollapsedChange( + !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, + binding.notificationContent, + animateEmojis + ) + setClickableText( + binding.notificationContent, + emojifiedText, + statusViewData.actionable.mentions, + statusViewData.actionable.tags, + listener + ) + val emojifiedContentWarning: CharSequence = statusViewData.spoilerText.emojify( + statusViewData.actionable.emojis, + binding.notificationContentWarningDescription, + animateEmojis + ) + binding.notificationContentWarningDescription.text = emojifiedContentWarning + } + + companion object { + private val COLLAPSE_INPUT_FILTER = arrayOf(SmartLengthInputFilter) + private val NO_INPUT_FILTER = arrayOfNulls(0) + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/notifications/StatusViewHolder.kt b/app/src/main/java/com/keylesspalace/tusky/components/notifications/StatusViewHolder.kt new file mode 100644 index 000000000..c719c084a --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/notifications/StatusViewHolder.kt @@ -0,0 +1,60 @@ +/* + * 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 . + */ + +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 +) : NotificationsPagingAdapter.ViewHolder, StatusViewHolder(binding.root) { + + override fun bind( + viewData: NotificationViewData, + payloads: List<*>?, + statusDisplayOptions: StatusDisplayOptions + ) { + val statusViewData = viewData.statusViewData + if (statusViewData == null) { + // Hide null statuses. Shouldn't happen according to the spec, but some servers + // have been seen to do this (https://github.com/tuskyapp/Tusky/issues/2252) + showStatusContent(false) + } else { + if (payloads.isNullOrEmpty()) { + showStatusContent(true) + } + setupWithStatus( + statusViewData, + statusActionListener, + statusDisplayOptions, + payloads?.firstOrNull() + ) + } + if (viewData.type == Notification.Type.POLL) { + setPollInfo(accountId == viewData.account.id) + } else { + hideStatusInfo() + } + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/report/fragments/ReportStatusesFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/report/fragments/ReportStatusesFragment.kt index f65e29c3a..f15f19629 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/report/fragments/ReportStatusesFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/report/fragments/ReportStatusesFragment.kt @@ -156,7 +156,9 @@ class ReportStatusesFragment : confirmReblogs = preferences.getBoolean("confirmReblogs", true), confirmFavourites = preferences.getBoolean("confirmFavourites", false), hideStats = preferences.getBoolean(PrefKeys.WELLBEING_HIDE_STATS_POSTS, false), - animateEmojis = preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false) + animateEmojis = preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false), + showSensitiveMedia = accountManager.activeAccount!!.alwaysShowSensitiveMedia, + openSpoiler = accountManager.activeAccount!!.alwaysOpenSpoiler ) adapter = StatusesAdapter(statusDisplayOptions, viewModel.statusViewState, this) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/search/fragments/SearchStatusesFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/search/fragments/SearchStatusesFragment.kt index 12aeaf81b..1b3c39f95 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/search/fragments/SearchStatusesFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/search/fragments/SearchStatusesFragment.kt @@ -48,6 +48,7 @@ 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.entity.Attachment import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.entity.Status.Mention @@ -62,8 +63,11 @@ import com.keylesspalace.tusky.viewdata.AttachmentViewData import com.keylesspalace.tusky.viewdata.StatusViewData import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.launch +import javax.inject.Inject class SearchStatusesFragment : SearchFragment(), StatusActionListener { + @Inject + lateinit var accountManager: AccountManager override val data: Flow> get() = viewModel.statusesFlow @@ -83,7 +87,9 @@ class SearchStatusesFragment : SearchFragment(), Status confirmReblogs = preferences.getBoolean("confirmReblogs", true), confirmFavourites = preferences.getBoolean("confirmFavourites", false), hideStats = preferences.getBoolean(PrefKeys.WELLBEING_HIDE_STATS_POSTS, false), - animateEmojis = preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false) + animateEmojis = preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false), + showSensitiveMedia = accountManager.activeAccount!!.alwaysShowSensitiveMedia, + openSpoiler = accountManager.activeAccount!!.alwaysOpenSpoiler ) binding.searchRecyclerView.addItemDecoration(DividerItemDecoration(binding.searchRecyclerView.context, DividerItemDecoration.VERTICAL)) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt index 36d20e687..317d8df1b 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt @@ -191,7 +191,9 @@ class TimelineFragment : 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) + animateEmojis = preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false), + showSensitiveMedia = accountManager.activeAccount!!.alwaysShowSensitiveMedia, + openSpoiler = accountManager.activeAccount!!.alwaysOpenSpoiler ) adapter = TimelinePagingAdapter( statusDisplayOptions, @@ -226,16 +228,16 @@ class TimelineFragment : 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, null) + binding.statusView.setup(R.drawable.elephant_friend_empty, R.string.message_empty) } } is LoadState.Error -> { binding.statusView.show() if ((loadState.refresh as LoadState.Error).error is IOException) { - binding.statusView.setup(R.drawable.elephant_offline, R.string.error_network, null) + binding.statusView.setup(R.drawable.elephant_offline, R.string.error_network) } else { - binding.statusView.setup(R.drawable.elephant_error, R.string.error_generic, null) + binding.statusView.setup(R.drawable.elephant_error, R.string.error_generic) } } is LoadState.Loading -> { diff --git a/app/src/main/java/com/keylesspalace/tusky/components/viewthread/ViewThreadFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/viewthread/ViewThreadFragment.kt index 4baa0ff12..c780ffeb1 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/viewthread/ViewThreadFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/viewthread/ViewThreadFragment.kt @@ -112,7 +112,9 @@ class ViewThreadFragment : confirmReblogs = preferences.getBoolean("confirmReblogs", true), confirmFavourites = preferences.getBoolean("confirmFavourites", false), hideStats = preferences.getBoolean(PrefKeys.WELLBEING_HIDE_STATS_POSTS, false), - animateEmojis = preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false) + animateEmojis = preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false), + showSensitiveMedia = accountManager.activeAccount!!.alwaysShowSensitiveMedia, + openSpoiler = accountManager.activeAccount!!.alwaysOpenSpoiler ) adapter = ThreadAdapter(statusDisplayOptions, this) } diff --git a/app/src/main/java/com/keylesspalace/tusky/db/AccountEntity.kt b/app/src/main/java/com/keylesspalace/tusky/db/AccountEntity.kt index 852088f33..418c77e32 100644 --- a/app/src/main/java/com/keylesspalace/tusky/db/AccountEntity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/db/AccountEntity.kt @@ -64,6 +64,11 @@ data class AccountEntity( var alwaysShowSensitiveMedia: Boolean = false, /** True if content behind a content warning is shown by default */ var alwaysOpenSpoiler: Boolean = false, + + /** + * True if the "Download media previews" preference is true. This implies + * that media previews are shown as well as downloaded. + */ var mediaPreviewEnabled: Boolean = true, var lastNotificationId: String = "0", var activeNotifications: String = "[]", diff --git a/app/src/main/java/com/keylesspalace/tusky/di/FragmentBuildersModule.kt b/app/src/main/java/com/keylesspalace/tusky/di/FragmentBuildersModule.kt index 3ad18fcac..aee1feab4 100644 --- a/app/src/main/java/com/keylesspalace/tusky/di/FragmentBuildersModule.kt +++ b/app/src/main/java/com/keylesspalace/tusky/di/FragmentBuildersModule.kt @@ -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.instancemute.fragment.InstanceListFragment +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 @@ -34,7 +35,6 @@ import com.keylesspalace.tusky.components.timeline.TimelineFragment import com.keylesspalace.tusky.components.trending.TrendingFragment import com.keylesspalace.tusky.components.viewthread.ViewThreadFragment import com.keylesspalace.tusky.components.viewthread.edits.ViewEditsFragment -import com.keylesspalace.tusky.fragment.NotificationsFragment import dagger.Module import dagger.android.ContributesAndroidInjector diff --git a/app/src/main/java/com/keylesspalace/tusky/di/ViewModelFactory.kt b/app/src/main/java/com/keylesspalace/tusky/di/ViewModelFactory.kt index f852d97bb..e3ce3a3e6 100644 --- a/app/src/main/java/com/keylesspalace/tusky/di/ViewModelFactory.kt +++ b/app/src/main/java/com/keylesspalace/tusky/di/ViewModelFactory.kt @@ -1,3 +1,20 @@ +/* + * 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 . + */ + // from https://proandroiddev.com/viewmodel-with-dagger2-architecture-components-2e06f06c9455 package com.keylesspalace.tusky.di @@ -13,6 +30,7 @@ import com.keylesspalace.tusky.components.conversation.ConversationsViewModel import com.keylesspalace.tusky.components.drafts.DraftsViewModel 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 @@ -145,6 +163,11 @@ abstract class ViewModelModule { @ViewModelKey(ListsForAccountViewModel::class) internal abstract fun listsForAccountViewModel(viewModel: ListsForAccountViewModel): ViewModel + @Binds + @IntoMap + @ViewModelKey(NotificationsViewModel::class) + internal abstract fun notificationsViewModel(viewModel: NotificationsViewModel): ViewModel + @Binds @IntoMap @ViewModelKey(TrendingViewModel::class) diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/Notification.kt b/app/src/main/java/com/keylesspalace/tusky/entity/Notification.kt index b058c4c10..1bad6697b 100644 --- a/app/src/main/java/com/keylesspalace/tusky/entity/Notification.kt +++ b/app/src/main/java/com/keylesspalace/tusky/entity/Notification.kt @@ -15,11 +15,13 @@ package com.keylesspalace.tusky.entity +import androidx.annotation.StringRes import com.google.gson.JsonDeserializationContext import com.google.gson.JsonDeserializer import com.google.gson.JsonElement import com.google.gson.JsonParseException import com.google.gson.annotations.JsonAdapter +import com.keylesspalace.tusky.R data class Notification( val type: Type, @@ -29,23 +31,42 @@ data class Notification( val report: Report?, ) { + /** From https://docs.joinmastodon.org/entities/Notification/#type */ @JsonAdapter(NotificationTypeAdapter::class) - enum class Type(val presentation: String) { - UNKNOWN("unknown"), - MENTION("mention"), - REBLOG("reblog"), - FAVOURITE("favourite"), - FOLLOW("follow"), - FOLLOW_REQUEST("follow_request"), - POLL("poll"), - STATUS("status"), - SIGN_UP("admin.sign_up"), - UPDATE("update"), - REPORT("admin.report"), - ; + enum class Type(val presentation: String, @StringRes val uiString: Int) { + UNKNOWN("unknown", R.string.notification_unknown_name), + + /** Someone mentioned you */ + MENTION("mention", R.string.notification_mention_name), + + /** Someone boosted one of your statuses */ + REBLOG("reblog", R.string.notification_boost_name), + + /** Someone favourited one of your statuses */ + FAVOURITE("favourite", R.string.notification_favourite_name), + + /** Someone followed you */ + FOLLOW("follow", R.string.notification_follow_name), + + /** Someone requested to follow you */ + FOLLOW_REQUEST("follow_request", R.string.notification_follow_request_name), + + /** A poll you have voted in or created has ended */ + POLL("poll", R.string.notification_poll_name), + + /** Someone you enabled notifications for has posted a status */ + STATUS("status", R.string.notification_subscription_name), + + /** Someone signed up (optionally sent to admins) */ + SIGN_UP("admin.sign_up", R.string.notification_sign_up_name), + + /** A status you interacted with has been updated */ + UPDATE("update", R.string.notification_update_name), + + /** A new report has been filed */ + REPORT("admin.report", R.string.notification_report_name); companion object { - @JvmStatic fun byString(s: String): Type { values().forEach { @@ -54,7 +75,9 @@ data class Notification( } return UNKNOWN } - val asList = listOf(MENTION, REBLOG, FAVOURITE, FOLLOW, FOLLOW_REQUEST, POLL, STATUS, SIGN_UP, UPDATE, REPORT) + + /** Notification types for UI display (omits UNKNOWN) */ + val visibleTypes = listOf(MENTION, REBLOG, FAVOURITE, FOLLOW, FOLLOW_REQUEST, POLL, STATUS, SIGN_UP, UPDATE, REPORT) } override fun toString(): String { @@ -86,9 +109,6 @@ data class Notification( } } - /** 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) { diff --git a/app/src/main/java/com/keylesspalace/tusky/fragment/NotificationsFragment.java b/app/src/main/java/com/keylesspalace/tusky/fragment/NotificationsFragment.java deleted file mode 100644 index 24d026ccb..000000000 --- a/app/src/main/java/com/keylesspalace/tusky/fragment/NotificationsFragment.java +++ /dev/null @@ -1,1273 +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 . */ - -package com.keylesspalace.tusky.fragment; - -import static com.keylesspalace.tusky.util.StringUtils.isLessThan; -import static autodispose2.AutoDispose.autoDisposable; -import static autodispose2.androidx.lifecycle.AndroidLifecycleScopeProvider.from; - -import android.app.Activity; -import android.content.Context; -import android.content.DialogInterface; -import android.content.SharedPreferences; -import android.os.Bundle; -import android.util.Log; -import android.util.SparseBooleanArray; -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.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.appcompat.app.AlertDialog; -import androidx.arch.core.util.Function; -import androidx.coordinatorlayout.widget.CoordinatorLayout; -import androidx.core.util.Pair; -import androidx.core.view.MenuProvider; -import androidx.lifecycle.Lifecycle; -import androidx.preference.PreferenceManager; -import androidx.recyclerview.widget.AsyncDifferConfig; -import androidx.recyclerview.widget.AsyncListDiffer; -import androidx.recyclerview.widget.DiffUtil; -import androidx.recyclerview.widget.DividerItemDecoration; -import androidx.recyclerview.widget.LinearLayoutManager; -import androidx.recyclerview.widget.ListUpdateCallback; -import androidx.recyclerview.widget.RecyclerView; -import androidx.recyclerview.widget.SimpleItemAnimator; -import androidx.swiperefreshlayout.widget.SwipeRefreshLayout; - -import com.google.android.material.appbar.AppBarLayout; -import com.google.android.material.floatingactionbutton.FloatingActionButton; -import com.keylesspalace.tusky.R; -import com.keylesspalace.tusky.adapter.NotificationsAdapter; -import com.keylesspalace.tusky.adapter.StatusBaseViewHolder; -import com.keylesspalace.tusky.appstore.BlockEvent; -import com.keylesspalace.tusky.appstore.BookmarkEvent; -import com.keylesspalace.tusky.appstore.EventHub; -import com.keylesspalace.tusky.appstore.FavoriteEvent; -import com.keylesspalace.tusky.appstore.PinEvent; -import com.keylesspalace.tusky.appstore.PreferenceChangedEvent; -import com.keylesspalace.tusky.appstore.ReblogEvent; -import com.keylesspalace.tusky.components.notifications.NotificationHelper; -import com.keylesspalace.tusky.databinding.FragmentTimelineNotificationsBinding; -import com.keylesspalace.tusky.db.AccountEntity; -import com.keylesspalace.tusky.db.AccountManager; -import com.keylesspalace.tusky.di.Injectable; -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.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.Either; -import com.keylesspalace.tusky.util.HttpHeaderLink; -import com.keylesspalace.tusky.util.LinkHelper; -import com.keylesspalace.tusky.util.ListStatusAccessibilityDelegate; -import com.keylesspalace.tusky.util.ListUtils; -import com.keylesspalace.tusky.util.NotificationTypeConverterKt; -import com.keylesspalace.tusky.util.PairedList; -import com.keylesspalace.tusky.util.StatusDisplayOptions; -import com.keylesspalace.tusky.util.ViewDataUtils; -import com.keylesspalace.tusky.view.EndlessOnScrollListener; -import com.keylesspalace.tusky.viewdata.AttachmentViewData; -import com.keylesspalace.tusky.viewdata.NotificationViewData; -import com.keylesspalace.tusky.viewdata.StatusViewData; - -import java.io.IOException; -import java.util.ArrayList; -import java.util.Collections; -import java.util.HashSet; -import java.util.Iterator; -import java.util.List; -import java.util.Locale; -import java.util.Objects; -import java.util.Set; -import java.util.concurrent.TimeUnit; - -import javax.inject.Inject; - -import at.connyduck.sparkbutton.helpers.Utils; -import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers; -import io.reactivex.rxjava3.core.Observable; -import io.reactivex.rxjava3.core.Single; -import io.reactivex.rxjava3.disposables.CompositeDisposable; -import io.reactivex.rxjava3.disposables.Disposable; -import kotlin.Unit; -import kotlin.collections.CollectionsKt; -import kotlin.jvm.functions.Function1; - -public class NotificationsFragment extends SFragment implements - SwipeRefreshLayout.OnRefreshListener, - StatusActionListener, - NotificationsAdapter.NotificationActionListener, - AccountActionListener, - Injectable, - MenuProvider, - ReselectableFragment { - private static final String TAG = "NotificationF"; // logging tag - - private static final int LOAD_AT_ONCE = 30; - private int maxPlaceholderId = 0; - - private final Set notificationFilter = new HashSet<>(); - - private final CompositeDisposable disposables = new CompositeDisposable(); - - private enum FetchEnd { - TOP, - BOTTOM, - MIDDLE - } - - /** - * Placeholder for the notificationsEnabled. Consider moving to the separate class to hide constructor - * and reuse in different places as needed. - */ - private static final class Placeholder { - final long id; - - public static Placeholder getInstance(long id) { - return new Placeholder(id); - } - - private Placeholder(long id) { - this.id = id; - } - } - - @Inject - AccountManager accountManager; - @Inject - EventHub eventHub; - - private FragmentTimelineNotificationsBinding binding; - - private LinearLayoutManager layoutManager; - private EndlessOnScrollListener scrollListener; - private NotificationsAdapter adapter; - private boolean hideFab; - private boolean topLoading; - private boolean bottomLoading; - private String bottomId; - private boolean alwaysShowSensitiveMedia; - private boolean alwaysOpenSpoiler; - private boolean showNotificationsFilter; - private boolean showingError; - - // Each element is either a Notification for loading data or a Placeholder - private final PairedList, NotificationViewData> notifications - = new PairedList<>(new Function<>() { - @Override - public NotificationViewData apply(Either input) { - if (input.isRight()) { - Notification notification = input.asRight() - .rewriteToStatusTypeIfNeeded(accountManager.getActiveAccount().getAccountId()); - - boolean sensitiveStatus = notification.getStatus() != null && notification.getStatus().getActionableStatus().getSensitive(); - - return ViewDataUtils.notificationToViewData( - notification, - alwaysShowSensitiveMedia || !sensitiveStatus, - alwaysOpenSpoiler, - true - ); - } else { - return new NotificationViewData.Placeholder(input.asLeft().id, false); - } - } - }); - - public static NotificationsFragment newInstance() { - NotificationsFragment fragment = new NotificationsFragment(); - Bundle arguments = new Bundle(); - fragment.setArguments(arguments); - return fragment; - } - - @Nullable - @Override - public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, - @Nullable Bundle savedInstanceState) { - requireActivity().addMenuProvider(this, getViewLifecycleOwner(), Lifecycle.State.RESUMED); - - binding = FragmentTimelineNotificationsBinding.inflate(inflater, container, false); - - @NonNull Context context = inflater.getContext(); // from inflater to silence warning - SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(requireContext()); - - boolean showNotificationsFilterSetting = preferences.getBoolean("showNotificationsFilter", true); - // Clear notifications on filter visibility change to force refresh - if (showNotificationsFilterSetting != showNotificationsFilter) - notifications.clear(); - showNotificationsFilter = showNotificationsFilterSetting; - - // Setup the SwipeRefreshLayout. - binding.swipeRefreshLayout.setOnRefreshListener(this); - binding.swipeRefreshLayout.setColorSchemeResources(R.color.tusky_blue); - - loadNotificationsFilter(); - - // Setup the RecyclerView. - binding.recyclerView.setHasFixedSize(true); - layoutManager = new LinearLayoutManager(context); - binding.recyclerView.setLayoutManager(layoutManager); - binding.recyclerView.setAccessibilityDelegateCompat( - new ListStatusAccessibilityDelegate(binding.recyclerView, this, (pos) -> { - NotificationViewData notification = notifications.getPairedItemOrNull(pos); - // We support replies only for now - if (notification instanceof NotificationViewData.Concrete) { - return ((NotificationViewData.Concrete) notification).getStatusViewData(); - } else { - return null; - } - })); - - binding.recyclerView.addItemDecoration(new DividerItemDecoration(context, DividerItemDecoration.VERTICAL)); - - StatusDisplayOptions statusDisplayOptions = new StatusDisplayOptions( - preferences.getBoolean("animateGifAvatars", false), - accountManager.getActiveAccount().getMediaPreviewEnabled(), - preferences.getBoolean("absoluteTimeView", false), - preferences.getBoolean("showBotOverlay", true), - preferences.getBoolean("useBlurhash", true), - CardViewMode.NONE, - preferences.getBoolean("confirmReblogs", true), - preferences.getBoolean("confirmFavourites", false), - preferences.getBoolean(PrefKeys.WELLBEING_HIDE_STATS_POSTS, false), - preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false) - ); - - adapter = new NotificationsAdapter(accountManager.getActiveAccount().getAccountId(), - dataSource, statusDisplayOptions, this, this, this); - alwaysShowSensitiveMedia = accountManager.getActiveAccount().getAlwaysShowSensitiveMedia(); - alwaysOpenSpoiler = accountManager.getActiveAccount().getAlwaysOpenSpoiler(); - binding.recyclerView.setAdapter(adapter); - - topLoading = false; - bottomLoading = false; - bottomId = null; - - updateAdapter(); - - binding.buttonClear.setOnClickListener(v -> confirmClearNotifications()); - binding.buttonFilter.setOnClickListener(v -> showFilterMenu()); - - if (notifications.isEmpty()) { - binding.swipeRefreshLayout.setEnabled(false); - sendFetchNotificationsRequest(null, null, FetchEnd.BOTTOM, -1); - } else { - binding.progressBar.setVisibility(View.GONE); - } - - ((SimpleItemAnimator) binding.recyclerView.getItemAnimator()).setSupportsChangeAnimations(false); - - updateFilterVisibility(); - - return binding.getRoot(); - } - - @Override - public void onDestroyView() { - super.onDestroyView(); - binding = null; - } - - @Override - public void onCreateMenu(@NonNull Menu menu, @NonNull MenuInflater menuInflater) { - menuInflater.inflate(R.menu.fragment_notifications, menu); - } - - @Override - public boolean onMenuItemSelected(@NonNull MenuItem menuItem) { - if (menuItem.getItemId() == R.id.action_refresh) { - binding.swipeRefreshLayout.setRefreshing(true); - onRefresh(); - return true; - } - - return false; - } - - private void updateFilterVisibility() { - CoordinatorLayout.LayoutParams params = - (CoordinatorLayout.LayoutParams) binding.swipeRefreshLayout.getLayoutParams(); - if (showNotificationsFilter && !showingError) { - binding.appBarOptions.setExpanded(true, false); - binding.appBarOptions.setVisibility(View.VISIBLE); - // Set content behaviour to hide filter on scroll - params.setBehavior(new AppBarLayout.ScrollingViewBehavior()); - } else { - binding.appBarOptions.setExpanded(false, false); - binding.appBarOptions.setVisibility(View.GONE); - // Clear behaviour to hide app bar - params.setBehavior(null); - } - } - - private void confirmClearNotifications() { - new AlertDialog.Builder(requireContext()) - .setMessage(R.string.notification_clear_text) - .setPositiveButton(android.R.string.ok, (DialogInterface dia, int which) -> clearNotifications()) - .setNegativeButton(android.R.string.cancel, null) - .show(); - } - - @Override - public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { - super.onViewCreated(view, savedInstanceState); - Activity activity = getActivity(); - if (activity == null) throw new AssertionError("Activity is null"); - - // This is delayed until onActivityCreated solely because MainActivity.composeButton - // isn't guaranteed to be set until then. - // Use a modified scroll listener that both loads more notificationsEnabled as it - // goes, and hides the compose button on down-scroll. - SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(activity); - hideFab = preferences.getBoolean("fabHide", false); - scrollListener = new EndlessOnScrollListener(layoutManager) { - @Override - public void onScrolled(@NonNull RecyclerView view, int dx, int dy) { - super.onScrolled(view, dx, dy); - - ActionButtonActivity activity = (ActionButtonActivity) getActivity(); - FloatingActionButton composeButton = activity.getActionButton(); - - 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(); - } - } - } - - @Override - public void onLoadMore(int totalItemsCount, @NonNull RecyclerView view) { - NotificationsFragment.this.onLoadMore(); - } - }; - - binding.recyclerView.addOnScrollListener(scrollListener); - - eventHub.getEvents() - .observeOn(AndroidSchedulers.mainThread()) - .to(autoDisposable(from(this, Lifecycle.Event.ON_DESTROY))) - .subscribe(event -> { - if (event instanceof FavoriteEvent) { - setFavouriteForStatus(((FavoriteEvent) event).getStatusId(), ((FavoriteEvent) event).getFavourite()); - } else if (event instanceof BookmarkEvent) { - setBookmarkForStatus(((BookmarkEvent) event).getStatusId(), ((BookmarkEvent) event).getBookmark()); - } else if (event instanceof ReblogEvent) { - setReblogForStatus(((ReblogEvent) event).getStatusId(), ((ReblogEvent) event).getReblog()); - } else if (event instanceof PinEvent) { - setPinForStatus(((PinEvent) event).getStatusId(), ((PinEvent) event).getPinned()); - } else if (event instanceof BlockEvent) { - removeAllByAccountId(((BlockEvent) event).getAccountId()); - } else if (event instanceof PreferenceChangedEvent) { - onPreferenceChanged(((PreferenceChangedEvent) event).getPreferenceKey()); - } - }); - } - - @Override - public void onRefresh() { - binding.statusView.setVisibility(View.GONE); - this.showingError = false; - Either first = CollectionsKt.firstOrNull(this.notifications); - String topId; - if (first != null && first.isRight()) { - topId = first.asRight().getId(); - } else { - topId = null; - } - sendFetchNotificationsRequest(null, topId, FetchEnd.TOP, -1); - } - - @Override - public void onReply(int position) { - super.reply(notifications.get(position).asRight().getStatus()); - } - - @Override - public void onReblog(final boolean reblog, final int position) { - final Notification notification = notifications.get(position).asRight(); - final Status status = notification.getStatus(); - Objects.requireNonNull(status, "Reblog on notification without status"); - timelineCases.reblog(status.getId(), reblog) - .observeOn(AndroidSchedulers.mainThread()) - .to(autoDisposable(from(this))) - .subscribe( - (newStatus) -> setReblogForStatus(status.getId(), reblog), - (t) -> Log.d(getClass().getSimpleName(), - "Failed to reblog status: " + status.getId(), t) - ); - } - - private void setReblogForStatus(String statusId, boolean reblog) { - updateStatus(statusId, (s) -> s.copyWithReblogged(reblog)); - } - - @Override - public void onFavourite(final boolean favourite, final int position) { - final Notification notification = notifications.get(position).asRight(); - final Status status = notification.getStatus(); - - timelineCases.favourite(status.getId(), favourite) - .observeOn(AndroidSchedulers.mainThread()) - .to(autoDisposable(from(this))) - .subscribe( - (newStatus) -> setFavouriteForStatus(status.getId(), favourite), - (t) -> Log.d(getClass().getSimpleName(), - "Failed to favourite status: " + status.getId(), t) - ); - } - - private void setFavouriteForStatus(String statusId, boolean favourite) { - updateStatus(statusId, (s) -> s.copyWithFavourited(favourite)); - } - - @Override - public void onBookmark(final boolean bookmark, final int position) { - final Notification notification = notifications.get(position).asRight(); - final Status status = notification.getStatus(); - - timelineCases.bookmark(status.getActionableId(), bookmark) - .observeOn(AndroidSchedulers.mainThread()) - .to(autoDisposable(from(this))) - .subscribe( - (newStatus) -> setBookmarkForStatus(status.getId(), bookmark), - (t) -> Log.d(getClass().getSimpleName(), - "Failed to bookmark status: " + status.getId(), t) - ); - } - - private void setBookmarkForStatus(String statusId, boolean bookmark) { - updateStatus(statusId, (s) -> s.copyWithBookmarked(bookmark)); - } - - public void onVoteInPoll(int position, @NonNull List choices) { - final Notification notification = notifications.get(position).asRight(); - final Status status = notification.getStatus().getActionableStatus(); - timelineCases.voteInPoll(status.getId(), status.getPoll().getId(), choices) - .observeOn(AndroidSchedulers.mainThread()) - .to(autoDisposable(from(this))) - .subscribe( - (newPoll) -> setVoteForPoll(status, newPoll), - (t) -> Log.d(TAG, - "Failed to vote in poll: " + status.getId(), t) - ); - } - - private void setVoteForPoll(Status status, Poll poll) { - updateStatus(status.getId(), (s) -> s.copyWithPoll(poll)); - } - - @Override - public void onMore(@NonNull View view, int position) { - Notification notification = notifications.get(position).asRight(); - super.more(notification.getStatus(), view, position); - } - - @Override - public void onViewMedia(int position, int attachmentIndex, @Nullable View view) { - Notification notification = notifications.get(position).asRightOrNull(); - if (notification == null || notification.getStatus() == null) return; - Status status = notification.getStatus(); - super.viewMedia(attachmentIndex, AttachmentViewData.list(status), view); - } - - @Override - public void onViewThread(int position) { - Notification notification = notifications.get(position).asRight(); - Status status = notification.getStatus(); - if (status == null) return; - super.viewThread(status.getActionableId(), status.getActionableStatus().getUrl()); - } - - @Override - public void onOpenReblog(int position) { - Notification notification = notifications.get(position).asRight(); - onViewAccount(notification.getAccount().getId()); - } - - @Override - public void onExpandedChange(boolean expanded, int position) { - updateViewDataAt(position, (vd) -> vd.copyWithExpanded(expanded)); - } - - @Override - public void onContentHiddenChange(boolean isShowing, int position) { - updateViewDataAt(position, (vd) -> vd.copyWithShowingContent(isShowing)); - } - - private void setPinForStatus(String statusId, boolean pinned) { - updateStatus(statusId, status -> status.copyWithPinned(pinned)); - } - - @Override - public void onLoadMore(int position) { - // Check bounds before accessing list, - if (notifications.size() >= position && position > 0) { - Notification previous = notifications.get(position - 1).asRightOrNull(); - Notification next = notifications.get(position + 1).asRightOrNull(); - if (previous == null || next == null) { - Log.e(TAG, "Failed to load more, invalid placeholder position: " + position); - return; - } - sendFetchNotificationsRequest(previous.getId(), next.getId(), FetchEnd.MIDDLE, position); - Placeholder placeholder = notifications.get(position).asLeft(); - NotificationViewData notificationViewData = - new NotificationViewData.Placeholder(placeholder.id, true); - notifications.setPairedItem(position, notificationViewData); - updateAdapter(); - } else { - Log.d(TAG, "error loading more"); - } - } - - @Override - public void onContentCollapsedChange(boolean isCollapsed, int position) { - updateViewDataAt(position, (vd) -> vd.copyWithCollapsed(isCollapsed)); - } - - private void updateStatus(String statusId, Function mapper) { - int index = CollectionsKt.indexOfFirst(this.notifications, (s) -> s.isRight() && - s.asRight().getStatus() != null && - s.asRight().getStatus().getId().equals(statusId)); - if (index == -1) return; - - // We have quite some graph here: - // - // Notification --------> Status - // ^ - // | - // StatusViewData - // ^ - // | - // NotificationViewData -----+ - // - // So if we have "new" status we need to update all references to be sure that data is - // up-to-date: - // 1. update status - // 2. update notification - // 3. update statusViewData - // 4. update notificationViewData - - Status oldStatus = notifications.get(index).asRight().getStatus(); - NotificationViewData.Concrete oldViewData = - (NotificationViewData.Concrete) this.notifications.getPairedItem(index); - Status newStatus = mapper.apply(oldStatus); - Notification newNotification = this.notifications.get(index).asRight() - .copyWithStatus(newStatus); - StatusViewData.Concrete newStatusViewData = - Objects.requireNonNull(oldViewData.getStatusViewData()).copyWithStatus(newStatus); - NotificationViewData.Concrete newViewData = oldViewData.copyWithStatus(newStatusViewData); - - notifications.set(index, new Either.Right<>(newNotification)); - notifications.setPairedItem(index, newViewData); - - updateAdapter(); - } - - private void updateViewDataAt(int position, - Function mapper) { - if (position < 0 || position >= notifications.size()) { - String message = String.format( - Locale.getDefault(), - "Tried to access out of bounds status position: %d of %d", - position, - notifications.size() - 1 - ); - Log.e(TAG, message); - return; - } - NotificationViewData someViewData = this.notifications.getPairedItem(position); - if (!(someViewData instanceof NotificationViewData.Concrete)) { - return; - } - NotificationViewData.Concrete oldViewData = (NotificationViewData.Concrete) someViewData; - StatusViewData.Concrete oldStatusViewData = oldViewData.getStatusViewData(); - if (oldStatusViewData == null) return; - - NotificationViewData.Concrete newViewData = - oldViewData.copyWithStatus(mapper.apply(oldStatusViewData)); - notifications.setPairedItem(position, newViewData); - - updateAdapter(); - } - - @Override - public void onNotificationContentCollapsedChange(boolean isCollapsed, int position) { - onContentCollapsedChange(isCollapsed, position); - } - - private void clearNotifications() { - // Cancel all ongoing requests - binding.swipeRefreshLayout.setRefreshing(false); - resetNotificationsLoad(); - - // Show friend elephant - binding.statusView.setVisibility(View.VISIBLE); - binding.statusView.setup(R.drawable.elephant_friend_empty, R.string.message_empty, null); - updateFilterVisibility(); - - // Update adapter - updateAdapter(); - - // Execute clear notifications request - mastodonApi.clearNotifications() - .observeOn(AndroidSchedulers.mainThread()) - .to(autoDisposable(from(this, Lifecycle.Event.ON_DESTROY))) - .subscribe( - response -> { - // Nothing to do - }, - throwable -> { - // Reload notifications on failure - fullyRefreshWithProgressBar(true); - }); - } - - private void resetNotificationsLoad() { - disposables.clear(); - bottomLoading = false; - topLoading = false; - - // Disable load more - bottomId = null; - - // Clear exists notifications - notifications.clear(); - } - - - private void showFilterMenu() { - List notificationsList = Notification.Type.Companion.getAsList(); - List list = new ArrayList<>(); - for (Notification.Type type : notificationsList) { - list.add(getNotificationText(type)); - } - - ArrayAdapter adapter = new ArrayAdapter<>(getContext(), android.R.layout.simple_list_item_multiple_choice, list); - PopupWindow window = new PopupWindow(getContext()); - View view = LayoutInflater.from(getContext()).inflate(R.layout.notifications_filter, (ViewGroup) getView(), false); - final ListView listView = view.findViewById(R.id.listView); - view.findViewById(R.id.buttonApply) - .setOnClickListener(v -> { - SparseBooleanArray checkedItems = listView.getCheckedItemPositions(); - Set excludes = new HashSet<>(); - for (int i = 0; i < notificationsList.size(); i++) { - if (!checkedItems.get(i, false)) - excludes.add(notificationsList.get(i)); - } - window.dismiss(); - applyFilterChanges(excludes); - - }); - - listView.setAdapter(adapter); - listView.setChoiceMode(ListView.CHOICE_MODE_MULTIPLE); - for (int i = 0; i < notificationsList.size(); i++) { - if (!notificationFilter.contains(notificationsList.get(i))) - listView.setItemChecked(i, true); - } - window.setContentView(view); - window.setFocusable(true); - window.setWidth(ViewGroup.LayoutParams.WRAP_CONTENT); - window.setHeight(ViewGroup.LayoutParams.WRAP_CONTENT); - window.showAsDropDown(binding.buttonFilter); - - } - - private String getNotificationText(Notification.Type type) { - switch (type) { - case MENTION: - return getString(R.string.notification_mention_name); - case FAVOURITE: - return getString(R.string.notification_favourite_name); - case REBLOG: - return getString(R.string.notification_boost_name); - case FOLLOW: - return getString(R.string.notification_follow_name); - case FOLLOW_REQUEST: - return getString(R.string.notification_follow_request_name); - case POLL: - return getString(R.string.notification_poll_name); - case STATUS: - return getString(R.string.notification_subscription_name); - case SIGN_UP: - return getString(R.string.notification_sign_up_name); - case UPDATE: - return getString(R.string.notification_update_name); - case REPORT: - return getString(R.string.notification_report_name); - default: - return "Unknown"; - } - } - - private void applyFilterChanges(Set newSet) { - List notifications = Notification.Type.Companion.getAsList(); - boolean isChanged = false; - for (Notification.Type type : notifications) { - if (notificationFilter.contains(type) && !newSet.contains(type)) { - notificationFilter.remove(type); - isChanged = true; - } else if (!notificationFilter.contains(type) && newSet.contains(type)) { - notificationFilter.add(type); - isChanged = true; - } - } - if (isChanged) { - saveNotificationsFilter(); - fullyRefreshWithProgressBar(true); - } - - } - - private void loadNotificationsFilter() { - AccountEntity account = accountManager.getActiveAccount(); - if (account != null) { - notificationFilter.clear(); - notificationFilter.addAll(NotificationTypeConverterKt.deserialize( - account.getNotificationsFilter())); - } - } - - private void saveNotificationsFilter() { - AccountEntity account = accountManager.getActiveAccount(); - if (account != null) { - account.setNotificationsFilter(NotificationTypeConverterKt.serialize(notificationFilter)); - accountManager.saveAccount(account); - } - } - - @Override - public void onViewTag(@NonNull String tag) { - super.viewTag(tag); - } - - @Override - public void onViewAccount(@NonNull String id) { - super.viewAccount(id); - } - - @Override - public void onMute(boolean mute, String id, int position, boolean notifications) { - // No muting from notifications yet - } - - @Override - public void onBlock(boolean block, String id, int position) { - // No blocking from notifications yet - } - - @Override - public void onRespondToFollowRequest(boolean accept, String id, int position) { - Single request = accept ? - mastodonApi.authorizeFollowRequest(id) : - mastodonApi.rejectFollowRequest(id); - request.observeOn(AndroidSchedulers.mainThread()) - .to(autoDisposable(from(this, Lifecycle.Event.ON_DESTROY))) - .subscribe( - (relationship) -> fullyRefreshWithProgressBar(true), - (error) -> Log.e(TAG, String.format("Failed to %s account id %s", accept ? "accept" : "reject", id)) - ); - } - - @Override - public void onViewStatusForNotificationId(String notificationId) { - for (Either either : notifications) { - Notification notification = either.asRightOrNull(); - if (notification != null && notification.getId().equals(notificationId)) { - Status status = notification.getStatus(); - if (status != null) { - super.viewThread(status.getActionableId(), status.getActionableStatus().getUrl()); - return; - } - } - } - Log.w(TAG, "Didn't find a notification for ID: " + notificationId); - } - - @Override - public void onViewReport(String reportId) { - LinkHelper.openLink(requireContext(), String.format("https://%s/admin/reports/%s", accountManager.getActiveAccount().getDomain(), reportId)); - } - - private void onPreferenceChanged(String key) { - switch (key) { - case "fabHide": { - hideFab = PreferenceManager.getDefaultSharedPreferences(requireContext()).getBoolean("fabHide", false); - break; - } - case "mediaPreviewEnabled": { - boolean enabled = accountManager.getActiveAccount().getMediaPreviewEnabled(); - if (enabled != adapter.isMediaPreviewEnabled()) { - adapter.setMediaPreviewEnabled(enabled); - fullyRefresh(); - } - break; - } - case "showNotificationsFilter": { - if (isAdded()) { - showNotificationsFilter = PreferenceManager.getDefaultSharedPreferences(requireContext()).getBoolean("showNotificationsFilter", true); - updateFilterVisibility(); - fullyRefreshWithProgressBar(true); - } - break; - } - } - } - - @Override - public void removeItem(int position) { - notifications.remove(position); - updateAdapter(); - } - - private void removeAllByAccountId(String accountId) { - // Using iterator to safely remove items while iterating - Iterator> iterator = notifications.iterator(); - while (iterator.hasNext()) { - Either notification = iterator.next(); - Notification maybeNotification = notification.asRightOrNull(); - if (maybeNotification != null && maybeNotification.getAccount().getId().equals(accountId)) { - iterator.remove(); - } - } - updateAdapter(); - } - - private void onLoadMore() { - if (bottomId == null) { - // Already loaded everything - return; - } - - // Check for out-of-bounds when loading - // This is required to allow full-timeline reloads of collapsible statuses when the settings - // change. - if (notifications.size() > 0) { - Either last = notifications.get(notifications.size() - 1); - if (last.isRight()) { - final Placeholder placeholder = newPlaceholder(); - notifications.add(new Either.Left<>(placeholder)); - NotificationViewData viewData = - new NotificationViewData.Placeholder(placeholder.id, true); - notifications.setPairedItem(notifications.size() - 1, viewData); - updateAdapter(); - } - } - - sendFetchNotificationsRequest(bottomId, null, FetchEnd.BOTTOM, -1); - } - - private Placeholder newPlaceholder() { - Placeholder placeholder = Placeholder.getInstance(maxPlaceholderId); - maxPlaceholderId--; - return placeholder; - } - - private void jumpToTop() { - if (isAdded()) { - binding.appBarOptions.setExpanded(true, false); - layoutManager.scrollToPosition(0); - scrollListener.reset(); - } - } - - private void sendFetchNotificationsRequest(String fromId, String uptoId, - final FetchEnd fetchEnd, final int pos) { - // If there is a fetch already ongoing, record however many fetches are requested and - // fulfill them after it's complete. - if (fetchEnd == FetchEnd.TOP && topLoading) { - return; - } - if (fetchEnd == FetchEnd.BOTTOM && bottomLoading) { - return; - } - if (fetchEnd == FetchEnd.TOP) { - topLoading = true; - } - if (fetchEnd == FetchEnd.BOTTOM) { - bottomLoading = true; - } - - Disposable notificationCall = mastodonApi.notifications(fromId, uptoId, LOAD_AT_ONCE, showNotificationsFilter ? notificationFilter : null) - .observeOn(AndroidSchedulers.mainThread()) - .to(autoDisposable(from(this, Lifecycle.Event.ON_DESTROY))) - .subscribe( - response -> { - if (response.isSuccessful()) { - String linkHeader = response.headers().get("Link"); - onFetchNotificationsSuccess(response.body(), linkHeader, fetchEnd, pos); - } else { - onFetchNotificationsFailure(new Exception(response.message()), fetchEnd, pos); - } - }, - throwable -> onFetchNotificationsFailure(throwable, fetchEnd, pos)); - disposables.add(notificationCall); - } - - private void onFetchNotificationsSuccess(List notifications, String linkHeader, - FetchEnd fetchEnd, int pos) { - List links = HttpHeaderLink.Companion.parse(linkHeader); - HttpHeaderLink next = HttpHeaderLink.Companion.findByRelationType(links, "next"); - String fromId = null; - if (next != null) { - fromId = next.getUri().getQueryParameter("max_id"); - } - - switch (fetchEnd) { - case TOP: { - update(notifications, this.notifications.isEmpty() ? fromId : null); - break; - } - case MIDDLE: { - replacePlaceholderWithNotifications(notifications, pos); - break; - } - case BOTTOM: { - - if (!this.notifications.isEmpty() - && !this.notifications.get(this.notifications.size() - 1).isRight()) { - this.notifications.remove(this.notifications.size() - 1); - updateAdapter(); - } - - if (adapter.getItemCount() > 1) { - addItems(notifications, fromId); - } else { - update(notifications, fromId); - } - - break; - } - } - - saveNewestNotificationId(notifications); - - if (fetchEnd == FetchEnd.TOP) { - topLoading = false; - } - if (fetchEnd == FetchEnd.BOTTOM) { - bottomLoading = false; - } - - if (notifications.size() == 0 && adapter.getItemCount() == 0) { - binding.statusView.setVisibility(View.VISIBLE); - binding.statusView.setup(R.drawable.elephant_friend_empty, R.string.message_empty, null); - } - - updateFilterVisibility(); - binding.swipeRefreshLayout.setEnabled(true); - binding.swipeRefreshLayout.setRefreshing(false); - binding.progressBar.setVisibility(View.GONE); - } - - private void onFetchNotificationsFailure(Throwable throwable, FetchEnd fetchEnd, int position) { - binding.swipeRefreshLayout.setRefreshing(false); - if (fetchEnd == FetchEnd.MIDDLE && !notifications.get(position).isRight()) { - Placeholder placeholder = notifications.get(position).asLeft(); - NotificationViewData placeholderVD = - new NotificationViewData.Placeholder(placeholder.id, false); - notifications.setPairedItem(position, placeholderVD); - updateAdapter(); - } else if (this.notifications.isEmpty()) { - binding.statusView.setVisibility(View.VISIBLE); - binding.swipeRefreshLayout.setEnabled(false); - this.showingError = true; - if (throwable instanceof IOException) { - binding.statusView.setup(R.drawable.elephant_offline, R.string.error_network, __ -> { - binding.progressBar.setVisibility(View.VISIBLE); - this.onRefresh(); - return Unit.INSTANCE; - }); - } else { - binding.statusView.setup(R.drawable.elephant_error, R.string.error_generic, __ -> { - binding.progressBar.setVisibility(View.VISIBLE); - this.onRefresh(); - return Unit.INSTANCE; - }); - } - updateFilterVisibility(); - } - Log.e(TAG, "Fetch failure: " + throwable.getMessage()); - - if (fetchEnd == FetchEnd.TOP) { - topLoading = false; - } - if (fetchEnd == FetchEnd.BOTTOM) { - bottomLoading = false; - } - - binding.progressBar.setVisibility(View.GONE); - } - - private void saveNewestNotificationId(List notifications) { - - AccountEntity account = accountManager.getActiveAccount(); - if (account != null) { - String lastNotificationId = account.getLastNotificationId(); - - for (Notification noti : notifications) { - if (isLessThan(lastNotificationId, noti.getId())) { - lastNotificationId = noti.getId(); - } - } - - if (!account.getLastNotificationId().equals(lastNotificationId)) { - Log.d(TAG, "saving newest noti id: " + lastNotificationId); - account.setLastNotificationId(lastNotificationId); - accountManager.saveAccount(account); - } - } - } - - private void update(@Nullable List newNotifications, @Nullable String fromId) { - if (ListUtils.isEmpty(newNotifications)) { - updateAdapter(); - return; - } - if (fromId != null) { - bottomId = fromId; - } - List> liftedNew = - liftNotificationList(newNotifications); - if (notifications.isEmpty()) { - notifications.addAll(liftedNew); - } else { - int index = notifications.indexOf(liftedNew.get(newNotifications.size() - 1)); - if (index > 0) { - notifications.subList(0, index).clear(); - } - - int newIndex = liftedNew.indexOf(notifications.get(0)); - if (newIndex == -1) { - if (index == -1 && liftedNew.size() >= LOAD_AT_ONCE) { - liftedNew.add(new Either.Left<>(newPlaceholder())); - } - notifications.addAll(0, liftedNew); - } else { - notifications.addAll(0, liftedNew.subList(0, newIndex)); - } - } - updateAdapter(); - } - - private void addItems(List newNotifications, @Nullable String fromId) { - bottomId = fromId; - if (ListUtils.isEmpty(newNotifications)) { - return; - } - int end = notifications.size(); - List> liftedNew = liftNotificationList(newNotifications); - Either last = notifications.get(end - 1); - if (last != null && !liftedNew.contains(last)) { - notifications.addAll(liftedNew); - updateAdapter(); - } - } - - private void replacePlaceholderWithNotifications(List newNotifications, int pos) { - // Remove placeholder - notifications.remove(pos); - - if (ListUtils.isEmpty(newNotifications)) { - updateAdapter(); - return; - } - - List> liftedNew = liftNotificationList(newNotifications); - - // If we fetched less posts than in the limit, it means that the hole is not filled - // If we fetched at least as much it means that there are more posts to load and we should - // insert new placeholder - if (newNotifications.size() >= LOAD_AT_ONCE) { - liftedNew.add(new Either.Left<>(newPlaceholder())); - } - - notifications.addAll(pos, liftedNew); - updateAdapter(); - } - - private final Function1> notificationLifter = - Either.Right::new; - - private List> liftNotificationList(List list) { - return CollectionsKt.map(list, notificationLifter); - } - - private void fullyRefreshWithProgressBar(boolean isShow) { - resetNotificationsLoad(); - if (isShow) { - binding.progressBar.setVisibility(View.VISIBLE); - binding.statusView.setVisibility(View.GONE); - } - updateAdapter(); - sendFetchNotificationsRequest(null, null, FetchEnd.TOP, -1); - } - - private void fullyRefresh() { - fullyRefreshWithProgressBar(false); - } - - @Nullable - private Pair findReplyPosition(@NonNull String statusId) { - for (int i = 0; i < notifications.size(); i++) { - Notification notification = notifications.get(i).asRightOrNull(); - if (notification != null - && notification.getStatus() != null - && notification.getType() == Notification.Type.MENTION - && (statusId.equals(notification.getStatus().getId()) - || (notification.getStatus().getReblog() != null - && statusId.equals(notification.getStatus().getReblog().getId())))) { - return new Pair<>(i, notification); - } - } - return null; - } - - private void updateAdapter() { - differ.submitList(notifications.getPairedCopy()); - } - - private final ListUpdateCallback listUpdateCallback = new ListUpdateCallback() { - @Override - public void onInserted(int position, int count) { - if (isAdded()) { - adapter.notifyItemRangeInserted(position, count); - Context context = getContext(); - // scroll up when new items at the top are loaded while being at the start - // https://github.com/tuskyapp/Tusky/pull/1905#issuecomment-677819724 - if (position == 0 && context != null && adapter.getItemCount() != count) { - binding.recyclerView.scrollBy(0, Utils.dpToPx(context, -30)); - } - } - } - - @Override - public void onRemoved(int position, int count) { - adapter.notifyItemRangeRemoved(position, count); - } - - @Override - public void onMoved(int fromPosition, int toPosition) { - adapter.notifyItemMoved(fromPosition, toPosition); - } - - @Override - public void onChanged(int position, int count, Object payload) { - adapter.notifyItemRangeChanged(position, count, payload); - } - }; - - private final AsyncListDiffer - differ = new AsyncListDiffer<>(listUpdateCallback, - new AsyncDifferConfig.Builder<>(diffCallback).build()); - - private final NotificationsAdapter.AdapterDataSource dataSource = - new NotificationsAdapter.AdapterDataSource<>() { - @Override - public int getItemCount() { - return differ.getCurrentList().size(); - } - - @Override - public NotificationViewData getItemAt(int pos) { - return differ.getCurrentList().get(pos); - } - }; - - private static final DiffUtil.ItemCallback diffCallback - = new DiffUtil.ItemCallback<>() { - - @Override - public boolean areItemsTheSame(NotificationViewData oldItem, NotificationViewData newItem) { - return oldItem.getViewDataId() == newItem.getViewDataId(); - } - - @Override - public boolean areContentsTheSame(@NonNull NotificationViewData oldItem, @NonNull NotificationViewData newItem) { - return false; - } - - @Nullable - @Override - public Object getChangePayload(@NonNull NotificationViewData oldItem, @NonNull NotificationViewData newItem) { - if (oldItem.deepEquals(newItem)) { - // If items are equal - update timestamp only - return Collections.singletonList(StatusBaseViewHolder.Key.KEY_CREATED); - } else - // If items are different - update a whole view holder - return null; - } - }; - - @Override - public void onResume() { - super.onResume(); - - NotificationHelper.clearNotificationsForActiveAccount(requireContext(), accountManager); - - String rawAccountNotificationFilter = accountManager.getActiveAccount().getNotificationsFilter(); - Set accountNotificationFilter = NotificationTypeConverterKt.deserialize(rawAccountNotificationFilter); - if (!notificationFilter.equals(accountNotificationFilter)) { - loadNotificationsFilter(); - fullyRefreshWithProgressBar(true); - } - startUpdateTimestamp(); - } - - /** - * Start to update adapter every minute to refresh timestamp - * If setting absoluteTimeView is false - * Auto dispose observable on pause - */ - private void startUpdateTimestamp() { - SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(requireContext()); - boolean useAbsoluteTime = preferences.getBoolean("absoluteTimeView", false); - if (!useAbsoluteTime) { - Observable.interval(0, 1, TimeUnit.MINUTES) - .observeOn(AndroidSchedulers.mainThread()) - .to(autoDisposable(from(this, Lifecycle.Event.ON_PAUSE))) - .subscribe( - interval -> updateAdapter() - ); - } - - } - - @Override - public void onReselect() { - jumpToTop(); - } -} diff --git a/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.kt b/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.kt index 6420216a0..a94c8a350 100644 --- a/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.kt +++ b/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.kt @@ -123,12 +123,22 @@ interface MastodonApi { ): Response> @GET("api/v1/notifications") - fun notifications( - @Query("max_id") maxId: String?, - @Query("since_id") sinceId: String?, - @Query("limit") limit: Int?, - @Query("exclude_types[]") excludes: Set? - ): Single>> + suspend fun notifications( + /** Return results older than this ID */ + @Query("max_id") maxId: 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? = null, + /** Types to excludes from the results */ + @Query("exclude_types[]") excludes: Set? = null + ): Response> + + /** Fetch a single notification */ + @GET("api/v1/notifications/{id}") + suspend fun notification( + @Path("id") id: String + ): Response @GET("api/v1/markers") fun markersWithAuth( @@ -145,7 +155,7 @@ interface MastodonApi { ): Single> @POST("api/v1/notifications/clear") - fun clearNotifications(): Single + suspend fun clearNotifications(): Response @FormUrlEncoded @PUT("api/v1/media/{mediaId}") diff --git a/app/src/main/java/com/keylesspalace/tusky/usecase/TimelineCases.kt b/app/src/main/java/com/keylesspalace/tusky/usecase/TimelineCases.kt index 45842f8e3..6f102bfcd 100644 --- a/app/src/main/java/com/keylesspalace/tusky/usecase/TimelineCases.kt +++ b/app/src/main/java/com/keylesspalace/tusky/usecase/TimelineCases.kt @@ -31,6 +31,7 @@ import com.keylesspalace.tusky.appstore.ReblogEvent import com.keylesspalace.tusky.appstore.StatusDeletedEvent import com.keylesspalace.tusky.entity.DeletedStatus import com.keylesspalace.tusky.entity.Poll +import com.keylesspalace.tusky.entity.Relationship import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.util.getServerErrorMessage @@ -143,6 +144,14 @@ class TimelineCases @Inject constructor( } } + fun acceptFollowRequest(accountId: String): Single { + return mastodonApi.authorizeFollowRequest(accountId) + } + + fun rejectFollowRequest(accountId: String): Single { + return mastodonApi.rejectFollowRequest(accountId) + } + private fun convertError(e: Throwable): Single { return Single.error(TimelineError(e.getServerErrorMessage())) } diff --git a/app/src/main/java/com/keylesspalace/tusky/util/StatusDisplayOptions.kt b/app/src/main/java/com/keylesspalace/tusky/util/StatusDisplayOptions.kt index cb7821073..7767accd0 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/StatusDisplayOptions.kt +++ b/app/src/main/java/com/keylesspalace/tusky/util/StatusDisplayOptions.kt @@ -1,5 +1,26 @@ +/* + * 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 . + */ + package com.keylesspalace.tusky.util +import android.content.SharedPreferences +import com.keylesspalace.tusky.db.AccountEntity +import com.keylesspalace.tusky.settings.PrefKeys + data class StatusDisplayOptions( @get:JvmName("animateAvatars") val animateAvatars: Boolean, @@ -20,5 +41,86 @@ data class StatusDisplayOptions( @get:JvmName("hideStats") val hideStats: Boolean, @get:JvmName("animateEmojis") - val animateEmojis: Boolean -) + val animateEmojis: Boolean, + @get:JvmName("showSensitiveMedia") + val showSensitiveMedia: Boolean, + @get:JvmName("openSpoiler") + val openSpoiler: Boolean +) { + + /** + * @return a new StatusDisplayOptions adapted to whichever preference changed. + */ + fun make( + preferences: SharedPreferences, + key: String, + account: AccountEntity + ) = when (key) { + PrefKeys.ANIMATE_GIF_AVATARS -> copy( + animateAvatars = preferences.getBoolean(key, false) + ) + PrefKeys.MEDIA_PREVIEW_ENABLED -> copy( + mediaPreviewEnabled = account.mediaPreviewEnabled + ) + PrefKeys.ABSOLUTE_TIME_VIEW -> copy( + useAbsoluteTime = preferences.getBoolean(key, false) + ) + PrefKeys.SHOW_BOT_OVERLAY -> copy( + showBotOverlay = preferences.getBoolean(key, true) + ) + PrefKeys.USE_BLURHASH -> copy( + useBlurhash = preferences.getBoolean(key, true) + ) + PrefKeys.CONFIRM_FAVOURITES -> copy( + confirmFavourites = preferences.getBoolean(key, false) + ) + PrefKeys.CONFIRM_REBLOGS -> copy( + confirmReblogs = preferences.getBoolean(key, true) + ) + PrefKeys.WELLBEING_HIDE_STATS_POSTS -> copy( + hideStats = preferences.getBoolean(key, false) + ) + PrefKeys.ANIMATE_CUSTOM_EMOJIS -> copy( + animateEmojis = preferences.getBoolean(key, false) + ) + PrefKeys.ALWAYS_SHOW_SENSITIVE_MEDIA -> copy( + showSensitiveMedia = account.alwaysShowSensitiveMedia + ) + PrefKeys.ALWAYS_OPEN_SPOILER -> copy( + openSpoiler = account.alwaysOpenSpoiler + ) + else -> { this } + } + + companion object { + /** Preference keys that, if changed, affect StatusDisplayOptions */ + val prefKeys = setOf( + PrefKeys.ABSOLUTE_TIME_VIEW, + PrefKeys.ALWAYS_SHOW_SENSITIVE_MEDIA, + PrefKeys.ALWAYS_OPEN_SPOILER, + PrefKeys.ANIMATE_CUSTOM_EMOJIS, + PrefKeys.ANIMATE_GIF_AVATARS, + PrefKeys.CONFIRM_FAVOURITES, + PrefKeys.CONFIRM_REBLOGS, + PrefKeys.MEDIA_PREVIEW_ENABLED, + PrefKeys.SHOW_BOT_OVERLAY, + PrefKeys.USE_BLURHASH, + PrefKeys.WELLBEING_HIDE_STATS_POSTS + ) + + fun from(preferences: SharedPreferences, account: AccountEntity) = StatusDisplayOptions( + animateAvatars = preferences.getBoolean(PrefKeys.ANIMATE_GIF_AVATARS, false), + animateEmojis = preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false), + mediaPreviewEnabled = account.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 = 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), + showSensitiveMedia = account.alwaysShowSensitiveMedia, + openSpoiler = account.alwaysOpenSpoiler + ) + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/util/ViewDataUtils.kt b/app/src/main/java/com/keylesspalace/tusky/util/ViewDataUtils.kt index 516465120..37e0854be 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/ViewDataUtils.kt +++ b/app/src/main/java/com/keylesspalace/tusky/util/ViewDataUtils.kt @@ -1,3 +1,20 @@ +/* + * 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 . + */ + @file:JvmName("ViewDataUtils") /* Copyright 2017 Andrew Dawson @@ -44,8 +61,8 @@ fun Notification.toViewData( isShowingContent: Boolean, isExpanded: Boolean, isCollapsed: Boolean -): NotificationViewData.Concrete { - return NotificationViewData.Concrete( +): NotificationViewData { + return NotificationViewData( this.type, this.id, this.account, diff --git a/app/src/main/java/com/keylesspalace/tusky/viewdata/NotificationViewData.java b/app/src/main/java/com/keylesspalace/tusky/viewdata/NotificationViewData.java deleted file mode 100644 index c70e2fc71..000000000 --- a/app/src/main/java/com/keylesspalace/tusky/viewdata/NotificationViewData.java +++ /dev/null @@ -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 . */ - -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. - *

- * 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; - } - } -} diff --git a/app/src/main/java/com/keylesspalace/tusky/viewdata/NotificationViewData.kt b/app/src/main/java/com/keylesspalace/tusky/viewdata/NotificationViewData.kt new file mode 100644 index 000000000..759d633e2 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/viewdata/NotificationViewData.kt @@ -0,0 +1,43 @@ +/* + * 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 . + */ + +/* + * 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 . */ +package com.keylesspalace.tusky.viewdata + +import com.keylesspalace.tusky.entity.Notification +import com.keylesspalace.tusky.entity.Report +import com.keylesspalace.tusky.entity.TimelineAccount + +data class NotificationViewData( + val type: Notification.Type, + val id: String, + val account: TimelineAccount, + var statusViewData: StatusViewData.Concrete?, + val report: Report? +) diff --git a/app/src/main/java/com/keylesspalace/tusky/viewdata/StatusViewData.kt b/app/src/main/java/com/keylesspalace/tusky/viewdata/StatusViewData.kt index f7125dc5b..07b7f3db3 100644 --- a/app/src/main/java/com/keylesspalace/tusky/viewdata/StatusViewData.kt +++ b/app/src/main/java/com/keylesspalace/tusky/viewdata/StatusViewData.kt @@ -90,21 +90,6 @@ sealed class StatusViewData { this.isCollapsible = shouldTrimStatus(this.content) } - /** 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) diff --git a/app/src/main/res/layout-sw640dp/fragment_timeline_notifications.xml b/app/src/main/res/layout-sw640dp/fragment_timeline_notifications.xml index da5a204bd..db912f965 100644 --- a/app/src/main/res/layout-sw640dp/fragment_timeline_notifications.xml +++ b/app/src/main/res/layout-sw640dp/fragment_timeline_notifications.xml @@ -1,4 +1,21 @@ + + - \ No newline at end of file + diff --git a/app/src/main/res/layout/fragment_timeline_notifications.xml b/app/src/main/res/layout/fragment_timeline_notifications.xml index 8609453f2..5386f8ba1 100644 --- a/app/src/main/res/layout/fragment_timeline_notifications.xml +++ b/app/src/main/res/layout/fragment_timeline_notifications.xml @@ -1,4 +1,21 @@ + + + + + + + +