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 <connyduck@users.noreply.github.com>

* 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 <connyduck@users.noreply.github.com>
This commit is contained in:
Nik Clayton 2023-03-10 20:12:33 +01:00 committed by GitHub
parent ce6a350267
commit 4d401c7878
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
53 changed files with 4460 additions and 2262 deletions

View File

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

View File

@ -20,10 +20,10 @@ import androidx.annotation.DrawableRes
import androidx.annotation.StringRes
import androidx.fragment.app.Fragment
import com.keylesspalace.tusky.components.conversation.ConversationsFragment
import com.keylesspalace.tusky.components.notifications.NotificationsFragment
import com.keylesspalace.tusky.components.timeline.TimelineFragment
import com.keylesspalace.tusky.components.timeline.viewmodel.TimelineViewModel
import com.keylesspalace.tusky.components.trending.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 */

View File

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

View File

@ -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 <http://www.gnu.org/licenses>. */
package com.keylesspalace.tusky.adapter;
import android.content.Context;
import android.graphics.Color;
import android.graphics.PorterDuff;
import android.graphics.Typeface;
import android.graphics.drawable.Drawable;
import android.text.InputFilter;
import android.text.SpannableStringBuilder;
import android.text.Spanned;
import android.text.TextUtils;
import android.text.style.StyleSpan;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.Button;
import android.widget.ImageView;
import android.widget.TextView;
import androidx.annotation.ColorRes;
import androidx.annotation.DrawableRes;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.core.content.ContextCompat;
import androidx.recyclerview.widget.RecyclerView;
import com.bumptech.glide.Glide;
import com.keylesspalace.tusky.R;
import com.keylesspalace.tusky.databinding.ItemFollowRequestBinding;
import com.keylesspalace.tusky.databinding.ItemReportNotificationBinding;
import com.keylesspalace.tusky.entity.Emoji;
import com.keylesspalace.tusky.entity.Notification;
import com.keylesspalace.tusky.entity.Status;
import com.keylesspalace.tusky.entity.TimelineAccount;
import com.keylesspalace.tusky.interfaces.AccountActionListener;
import com.keylesspalace.tusky.interfaces.LinkListener;
import com.keylesspalace.tusky.interfaces.StatusActionListener;
import com.keylesspalace.tusky.util.AbsoluteTimeFormatter;
import com.keylesspalace.tusky.util.CardViewMode;
import com.keylesspalace.tusky.util.CustomEmojiHelper;
import com.keylesspalace.tusky.util.ImageLoadingHelper;
import com.keylesspalace.tusky.util.LinkHelper;
import com.keylesspalace.tusky.util.SmartLengthInputFilter;
import com.keylesspalace.tusky.util.StatusDisplayOptions;
import com.keylesspalace.tusky.util.StringUtils;
import com.keylesspalace.tusky.util.TimestampUtils;
import com.keylesspalace.tusky.viewdata.NotificationViewData;
import com.keylesspalace.tusky.viewdata.StatusViewData;
import java.util.Date;
import java.util.List;
import at.connyduck.sparkbutton.helpers.Utils;
public class NotificationsAdapter extends RecyclerView.Adapter<RecyclerView.ViewHolder> {
public interface AdapterDataSource<T> {
int getItemCount();
T getItemAt(int pos);
}
private static final int VIEW_TYPE_STATUS = 0;
private static final int VIEW_TYPE_STATUS_NOTIFICATION = 1;
private static final int VIEW_TYPE_FOLLOW = 2;
private static final int VIEW_TYPE_FOLLOW_REQUEST = 3;
private static final int VIEW_TYPE_PLACEHOLDER = 4;
private static final int VIEW_TYPE_REPORT = 5;
private static final int VIEW_TYPE_UNKNOWN = 6;
private static final InputFilter[] COLLAPSE_INPUT_FILTER = new InputFilter[]{SmartLengthInputFilter.INSTANCE};
private static final InputFilter[] NO_INPUT_FILTER = new InputFilter[0];
private final String accountId;
private StatusDisplayOptions statusDisplayOptions;
private final StatusActionListener statusListener;
private final NotificationActionListener notificationActionListener;
private final AccountActionListener accountActionListener;
private final AdapterDataSource<NotificationViewData> dataSource;
private final AbsoluteTimeFormatter absoluteTimeFormatter = new AbsoluteTimeFormatter();
public NotificationsAdapter(String accountId,
AdapterDataSource<NotificationViewData> dataSource,
StatusDisplayOptions statusDisplayOptions,
StatusActionListener statusListener,
NotificationActionListener notificationActionListener,
AccountActionListener accountActionListener) {
this.accountId = accountId;
this.dataSource = dataSource;
this.statusDisplayOptions = statusDisplayOptions;
this.statusListener = statusListener;
this.notificationActionListener = notificationActionListener;
this.accountActionListener = accountActionListener;
}
@NonNull
@Override
public RecyclerView.ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
LayoutInflater inflater = LayoutInflater.from(parent.getContext());
switch (viewType) {
case VIEW_TYPE_STATUS: {
View view = inflater
.inflate(R.layout.item_status, parent, false);
return new StatusViewHolder(view);
}
case VIEW_TYPE_STATUS_NOTIFICATION: {
View view = inflater
.inflate(R.layout.item_status_notification, parent, false);
return new StatusNotificationViewHolder(view, statusDisplayOptions, absoluteTimeFormatter);
}
case VIEW_TYPE_FOLLOW: {
View view = inflater
.inflate(R.layout.item_follow, parent, false);
return new FollowViewHolder(view, statusDisplayOptions);
}
case VIEW_TYPE_FOLLOW_REQUEST: {
ItemFollowRequestBinding binding = ItemFollowRequestBinding.inflate(inflater, parent, false);
return new FollowRequestViewHolder(binding, true);
}
case VIEW_TYPE_PLACEHOLDER: {
View view = inflater
.inflate(R.layout.item_status_placeholder, parent, false);
return new PlaceholderViewHolder(view);
}
case VIEW_TYPE_REPORT: {
ItemReportNotificationBinding binding = ItemReportNotificationBinding.inflate(inflater, parent, false);
return new ReportNotificationViewHolder(binding);
}
default:
case VIEW_TYPE_UNKNOWN: {
View view = new View(parent.getContext());
view.setLayoutParams(
new ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
Utils.dpToPx(parent.getContext(), 24)
)
);
return new RecyclerView.ViewHolder(view) {
};
}
}
}
@Override
public void onBindViewHolder(@NonNull RecyclerView.ViewHolder viewHolder, int position) {
bindViewHolder(viewHolder, position, null);
}
@Override
public void onBindViewHolder(@NonNull RecyclerView.ViewHolder viewHolder, int position, @NonNull List<Object> payloads) {
bindViewHolder(viewHolder, position, payloads);
}
private void bindViewHolder(@NonNull RecyclerView.ViewHolder viewHolder, int position, @Nullable List<Object> payloads) {
Object payloadForHolder = payloads != null && !payloads.isEmpty() ? payloads.get(0) : null;
if (position < this.dataSource.getItemCount()) {
NotificationViewData notification = dataSource.getItemAt(position);
if (notification instanceof NotificationViewData.Placeholder) {
if (payloadForHolder == null) {
NotificationViewData.Placeholder placeholder = ((NotificationViewData.Placeholder) notification);
PlaceholderViewHolder holder = (PlaceholderViewHolder) viewHolder;
holder.setup(statusListener, placeholder.isLoading());
}
return;
}
NotificationViewData.Concrete concreteNotification =
(NotificationViewData.Concrete) notification;
switch (viewHolder.getItemViewType()) {
case VIEW_TYPE_STATUS: {
StatusViewHolder holder = (StatusViewHolder) viewHolder;
StatusViewData.Concrete status = concreteNotification.getStatusViewData();
if (status == null) {
/* in some very rare cases servers sends null status even though they should not,
* we have to handle it somehow */
holder.showStatusContent(false);
} else {
if (payloads == null) {
holder.showStatusContent(true);
}
holder.setupWithStatus(status, statusListener, statusDisplayOptions, payloadForHolder);
}
if (concreteNotification.getType() == Notification.Type.POLL) {
holder.setPollInfo(accountId.equals(concreteNotification.getAccount().getId()));
} else {
holder.hideStatusInfo();
}
break;
}
case VIEW_TYPE_STATUS_NOTIFICATION: {
StatusNotificationViewHolder holder = (StatusNotificationViewHolder) viewHolder;
StatusViewData.Concrete statusViewData = concreteNotification.getStatusViewData();
if (payloadForHolder == null) {
if (statusViewData == null) {
/* in some very rare cases servers sends null status even though they should not,
* we have to handle it somehow */
holder.showNotificationContent(false);
} else {
holder.showNotificationContent(true);
Status status = statusViewData.getActionable();
holder.setDisplayName(status.getAccount().getDisplayName(), status.getAccount().getEmojis());
holder.setUsername(status.getAccount().getUsername());
holder.setCreatedAt(status.getCreatedAt());
if (concreteNotification.getType() == Notification.Type.STATUS ||
concreteNotification.getType() == Notification.Type.UPDATE) {
holder.setAvatar(status.getAccount().getAvatar(), status.getAccount().getBot());
} else {
holder.setAvatars(status.getAccount().getAvatar(),
concreteNotification.getAccount().getAvatar());
}
}
holder.setMessage(concreteNotification, statusListener);
holder.setupButtons(notificationActionListener,
concreteNotification.getAccount().getId(),
concreteNotification.getId());
} else {
if (payloadForHolder instanceof List)
for (Object item : (List<?>) payloadForHolder) {
if (StatusBaseViewHolder.Key.KEY_CREATED.equals(item) && statusViewData != null) {
holder.setCreatedAt(statusViewData.getStatus().getActionableStatus().getCreatedAt());
}
}
}
break;
}
case VIEW_TYPE_FOLLOW: {
if (payloadForHolder == null) {
FollowViewHolder holder = (FollowViewHolder) viewHolder;
holder.setMessage(concreteNotification.getAccount(), concreteNotification.getType() == Notification.Type.SIGN_UP);
holder.setupButtons(notificationActionListener, concreteNotification.getAccount().getId());
}
break;
}
case VIEW_TYPE_FOLLOW_REQUEST: {
if (payloadForHolder == null) {
FollowRequestViewHolder holder = (FollowRequestViewHolder) viewHolder;
holder.setupWithAccount(concreteNotification.getAccount(), statusDisplayOptions.animateAvatars(), statusDisplayOptions.animateEmojis(), statusDisplayOptions.showBotOverlay());
holder.setupActionListener(accountActionListener, concreteNotification.getAccount().getId());
}
break;
}
case VIEW_TYPE_REPORT: {
if (payloadForHolder == null) {
ReportNotificationViewHolder holder = (ReportNotificationViewHolder) viewHolder;
holder.setupWithReport(concreteNotification.getAccount(), concreteNotification.getReport(), statusDisplayOptions.animateAvatars(), statusDisplayOptions.animateEmojis());
holder.setupActionListener(notificationActionListener, concreteNotification.getReport().getTargetAccount().getId(), concreteNotification.getAccount().getId(), concreteNotification.getReport().getId());
}
}
default:
}
}
}
@Override
public int getItemCount() {
return dataSource.getItemCount();
}
public void setMediaPreviewEnabled(boolean mediaPreviewEnabled) {
this.statusDisplayOptions = statusDisplayOptions.copy(
statusDisplayOptions.animateAvatars(),
mediaPreviewEnabled,
statusDisplayOptions.useAbsoluteTime(),
statusDisplayOptions.showBotOverlay(),
statusDisplayOptions.useBlurhash(),
CardViewMode.NONE,
statusDisplayOptions.confirmReblogs(),
statusDisplayOptions.confirmFavourites(),
statusDisplayOptions.hideStats(),
statusDisplayOptions.animateEmojis()
);
}
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<Emoji> emojis) {
CharSequence emojifiedName = CustomEmojiHelper.emojify(name, emojis, displayName, statusDisplayOptions.animateEmojis());
displayName.setText(emojifiedName);
}
private void setUsername(String name) {
Context context = username.getContext();
String format = context.getString(R.string.post_username_format);
String usernameText = String.format(format, name);
username.setText(usernameText);
}
protected void setCreatedAt(@Nullable Date createdAt) {
if (statusDisplayOptions.useAbsoluteTime()) {
timestampInfo.setText(absoluteTimeFormatter.format(createdAt, true));
} else {
// This is the visible timestampInfo.
String readout;
/* This one is for screen-readers. Frequently, they would mispronounce timestamps like "17m"
* as 17 meters instead of minutes. */
CharSequence readoutAloud;
if (createdAt != null) {
long then = createdAt.getTime();
long now = new Date().getTime();
readout = TimestampUtils.getRelativeTimeSpanString(timestampInfo.getContext(), then, now);
readoutAloud = android.text.format.DateUtils.getRelativeTimeSpanString(then, now,
android.text.format.DateUtils.SECOND_IN_MILLIS,
android.text.format.DateUtils.FORMAT_ABBREV_RELATIVE);
} else {
// unknown minutes~
readout = "?m";
readoutAloud = "? minutes";
}
timestampInfo.setText(readout);
timestampInfo.setContentDescription(readoutAloud);
}
}
Drawable getIconWithColor(Context context, @DrawableRes int drawable, @ColorRes int color) {
Drawable icon = ContextCompat.getDrawable(context, drawable);
if (icon != null) {
icon.setColorFilter(context.getColor(color), PorterDuff.Mode.SRC_ATOP);
}
return icon;
}
void setMessage(NotificationViewData.Concrete notificationViewData, LinkListener listener) {
this.statusViewData = notificationViewData.getStatusViewData();
String displayName = StringUtils.unicodeWrap(notificationViewData.getAccount().getName());
Notification.Type type = notificationViewData.getType();
Context context = message.getContext();
String format;
Drawable icon;
switch (type) {
default:
case FAVOURITE: {
icon = getIconWithColor(context, R.drawable.ic_star_24dp, R.color.tusky_orange);
format = context.getString(R.string.notification_favourite_format);
break;
}
case REBLOG: {
icon = getIconWithColor(context, R.drawable.ic_repeat_24dp, R.color.tusky_blue);
format = context.getString(R.string.notification_reblog_format);
break;
}
case STATUS: {
icon = getIconWithColor(context, R.drawable.ic_home_24dp, R.color.tusky_blue);
format = context.getString(R.string.notification_subscription_format);
break;
}
case UPDATE: {
icon = getIconWithColor(context, R.drawable.ic_edit_24dp, R.color.tusky_blue);
format = context.getString(R.string.notification_update_format);
break;
}
}
message.setCompoundDrawablesWithIntrinsicBounds(icon, null, null, null);
String wholeMessage = String.format(format, displayName);
final SpannableStringBuilder str = new SpannableStringBuilder(wholeMessage);
int displayNameIndex = format.indexOf("%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<Emoji> emojis = statusViewData.getActionable().getEmojis();
if (statusViewData.isCollapsible() && (statusViewData.isExpanded() || !hasSpoiler)) {
contentCollapseButton.setOnClickListener(view -> {
int position = getBindingAdapterPosition();
if (position != RecyclerView.NO_POSITION && notificationActionListener != null) {
notificationActionListener.onNotificationContentCollapsedChange(!statusViewData.isCollapsed(), position);
}
});
contentCollapseButton.setVisibility(View.VISIBLE);
if (statusViewData.isCollapsed()) {
contentCollapseButton.setText(R.string.post_content_warning_show_more);
statusContent.setFilters(COLLAPSE_INPUT_FILTER);
} else {
contentCollapseButton.setText(R.string.post_content_warning_show_less);
statusContent.setFilters(NO_INPUT_FILTER);
}
} else {
contentCollapseButton.setVisibility(View.GONE);
statusContent.setFilters(NO_INPUT_FILTER);
}
CharSequence emojifiedText = CustomEmojiHelper.emojify(
content, emojis, statusContent, statusDisplayOptions.animateEmojis()
);
LinkHelper.setClickableText(statusContent, emojifiedText, statusViewData.getActionable().getMentions(), statusViewData.getActionable().getTags(), listener);
CharSequence emojifiedContentWarning;
if (statusViewData.getSpoilerText() != null) {
emojifiedContentWarning = CustomEmojiHelper.emojify(
statusViewData.getSpoilerText(),
statusViewData.getActionable().getEmojis(),
contentWarningDescriptionTextView,
statusDisplayOptions.animateEmojis()
);
} else {
emojifiedContentWarning = "";
}
contentWarningDescriptionTextView.setText(emojifiedContentWarning);
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

@ -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 <http://www.gnu.org/licenses>.
*/
package com.keylesspalace.tusky.components.notifications
import androidx.recyclerview.widget.RecyclerView
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.databinding.ItemFollowBinding
import com.keylesspalace.tusky.entity.Notification
import com.keylesspalace.tusky.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) }
}
}

View File

@ -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 <http://www.gnu.org/licenses>.
*/
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<NotificationActionSuccess>()
.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<StatusActionSuccess>()
.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<Int>) {
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<NotificationViewData> =
object : DiffUtil.ItemCallback<NotificationViewData>() {
override fun areItemsTheSame(
oldItem: NotificationViewData,
newItem: NotificationViewData
): Boolean {
return oldItem.id == newItem.id
}
override fun areContentsTheSame(
oldItem: NotificationViewData,
newItem: NotificationViewData
): Boolean {
return false
}
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<Notification.Type>,
private val listener: ((filter: Set<Notification.Type>) -> 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<Notification.Type> = 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()
}
}

View File

@ -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 <http://www.gnu.org/licenses>.
*/
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<NotificationsLoadStateViewHolder>() {
override fun onCreateViewHolder(
parent: ViewGroup,
loadState: LoadState
): NotificationsLoadStateViewHolder {
return NotificationsLoadStateViewHolder.create(parent, retry)
}
override fun onBindViewHolder(holder: NotificationsLoadStateViewHolder, loadState: LoadState) {
holder.bind(loadState)
}
}

View File

@ -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 <http://www.gnu.org/licenses>.
*/
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)
}
}
}

View File

@ -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 <http://www.gnu.org/licenses>.
*/
package com.keylesspalace.tusky.components.notifications
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.paging.PagingDataAdapter
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.RecyclerView
import com.keylesspalace.tusky.adapter.FollowRequestViewHolder
import com.keylesspalace.tusky.adapter.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<NotificationViewData>,
/** 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<NotificationViewData, RecyclerView.ViewHolder>(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<Any>
) {
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
}
}
}

View File

@ -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 <http://www.gnu.org/licenses>.
*/
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<Notification.Type>
) : PagingSource<String, Notification>() {
override suspend fun load(params: LoadParams<String>): LoadResult<String, Notification> {
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<String>): Response<List<Notification>> = 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: </?max_id=$maxId>; rel=\"next\", </?min_id=$key>; 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, Notification>): String? {
return state.anchorPosition?.let { anchorPosition ->
val anchorPage = state.closestPageToPosition(anchorPosition)
anchorPage?.prevKey ?: anchorPage?.nextKey
}
}
companion object {
private const val TAG = "NotificationsPagingSource"
}
}

View File

@ -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 <http://www.gnu.org/licenses>.
*/
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<String, Notification>? = null
/**
* @return flow of Mastodon [Notification], excluding all types in [filter].
* Notifications are loaded in [pageSize] increments.
*/
fun getNotificationsStream(
filter: Set<Notification.Type>,
pageSize: Int = PAGE_SIZE,
initialKey: String? = null
): Flow<PagingData<Notification>> {
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<ResponseBody> {
return mastodonApi.clearNotifications()
}
companion object {
private const val TAG = "NotificationsRepository"
private const val PAGE_SIZE = 30
}
}

View File

@ -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 <http://www.gnu.org/licenses>.
*/
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<Notification.Type> = 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<Notification.Type>) : 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<Int>,
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<UiState>
/** Flow of changes to statusDisplayOptions, for use by the UI */
val statusDisplayOptions: StateFlow<StatusDisplayOptions>
val pagingData: Flow<PagingData<NotificationViewData>>
/** Flow of user actions received from the UI */
private val uiAction = MutableSharedFlow<UiAction>()
/** 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<UiSuccess>()
/** Flow of transient errors for the UI to present */
val uiError = MutableSharedFlow<UiError>()
/** 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<InfallibleUiAction.ApplyFilter>()
.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<InfallibleUiAction.SaveVisibleId>()
.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<PreferenceChangedEvent>()
.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<FallibleUiAction.ClearNotifications>()
.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<NotificationAction>()
.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<StatusAction>()
.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<Notification.Type>,
initialKey: String? = null
): Flow<PagingData<NotificationViewData>> {
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<PreferenceChangedEvent>()
.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
}
}

View File

@ -150,7 +150,7 @@ fun disableAllNotifications(context: Context, accountManager: AccountManager) {
private fun buildSubscriptionData(context: Context, account: AccountEntity): Map<String, Boolean> =
buildMap {
Notification.Type.asList.forEach {
Notification.Type.visibleTypes.forEach {
put("data[alerts][${it.presentation}]", NotificationHelper.filterNotification(account, it, context))
}
}

View File

@ -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 <http://www.gnu.org/licenses>.
*/
package com.keylesspalace.tusky.components.notifications
import android.content.Context
import android.graphics.PorterDuff
import android.graphics.Typeface
import android.graphics.drawable.Drawable
import android.text.InputFilter
import android.text.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<Emoji>?, animateEmojis: Boolean) {
val emojifiedName = name.emojify(emojis, binding.statusDisplayName, animateEmojis)
binding.statusDisplayName.text = emojifiedName
}
private fun setUsername(name: String) {
val context = binding.statusUsername.context
val format = context.getString(R.string.post_username_format)
val usernameText = String.format(format, name)
binding.statusUsername.text = usernameText
}
private fun setCreatedAt(createdAt: Date?, useAbsoluteTime: Boolean) {
if (useAbsoluteTime) {
binding.statusMetaInfo.text = absoluteTimeFormatter.format(createdAt, true)
} else {
// 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<InputFilter>(SmartLengthInputFilter)
private val NO_INPUT_FILTER = arrayOfNulls<InputFilter>(0)
}
}

View File

@ -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 <http://www.gnu.org/licenses>.
*/
package com.keylesspalace.tusky.components.notifications
import com.keylesspalace.tusky.adapter.StatusViewHolder
import com.keylesspalace.tusky.databinding.ItemStatusBinding
import com.keylesspalace.tusky.entity.Notification
import com.keylesspalace.tusky.interfaces.StatusActionListener
import com.keylesspalace.tusky.util.StatusDisplayOptions
import com.keylesspalace.tusky.viewdata.NotificationViewData
internal class StatusViewHolder(
binding: ItemStatusBinding,
private val statusActionListener: StatusActionListener,
private val accountId: String
) : 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()
}
}
}

View File

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

View File

@ -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<StatusViewData.Concrete>(), StatusActionListener {
@Inject
lateinit var accountManager: AccountManager
override val data: Flow<PagingData<StatusViewData.Concrete>>
get() = viewModel.statusesFlow
@ -83,7 +87,9 @@ class SearchStatusesFragment : SearchFragment<StatusViewData.Concrete>(), 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))

View File

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

View File

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

View File

@ -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 = "[]",

View File

@ -21,6 +21,7 @@ import com.keylesspalace.tusky.components.account.media.AccountMediaFragment
import com.keylesspalace.tusky.components.accountlist.AccountListFragment
import com.keylesspalace.tusky.components.conversation.ConversationsFragment
import com.keylesspalace.tusky.components.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

View File

@ -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 <http://www.gnu.org/licenses>.
*/
// 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)

View File

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

View File

@ -123,12 +123,22 @@ interface MastodonApi {
): Response<List<Status>>
@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<Notification.Type>?
): Single<Response<List<Notification>>>
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<Notification.Type>? = null
): Response<List<Notification>>
/** Fetch a single notification */
@GET("api/v1/notifications/{id}")
suspend fun notification(
@Path("id") id: String
): Response<Notification>
@GET("api/v1/markers")
fun markersWithAuth(
@ -145,7 +155,7 @@ interface MastodonApi {
): Single<List<Notification>>
@POST("api/v1/notifications/clear")
fun clearNotifications(): Single<ResponseBody>
suspend fun clearNotifications(): Response<ResponseBody>
@FormUrlEncoded
@PUT("api/v1/media/{mediaId}")

View File

@ -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<Relationship> {
return mastodonApi.authorizeFollowRequest(accountId)
}
fun rejectFollowRequest(accountId: String): Single<Relationship> {
return mastodonApi.rejectFollowRequest(accountId)
}
private fun <T : Any> convertError(e: Throwable): Single<T> {
return Single.error(TimelineError(e.getServerErrorMessage()))
}

View File

@ -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 <http://www.gnu.org/licenses>.
*/
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
)
}
}

View File

@ -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 <http://www.gnu.org/licenses>.
*/
@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,

View File

@ -1,138 +0,0 @@
/* Copyright 2017 Andrew Dawson
*
* This file is a part of Tusky.
*
* This program is free software; you can redistribute it and/or modify it under the terms of the
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
* License, or (at your option) any later version.
*
* Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
* Public License for more details.
*
* You should have received a copy of the GNU General Public License along with Tusky; if not,
* see <http://www.gnu.org/licenses>. */
package com.keylesspalace.tusky.viewdata;
import androidx.annotation.Nullable;
import com.keylesspalace.tusky.entity.Notification;
import com.keylesspalace.tusky.entity.Report;
import com.keylesspalace.tusky.entity.TimelineAccount;
import java.util.Objects;
/**
* Created by charlag on 12/07/2017.
* <p>
* Class to represent data required to display either a notification or a placeholder.
* It is either a {@link Placeholder} or a {@link Concrete}.
* It is modelled this way because close relationship between placeholder and concrete notification
* is fine in this case. Placeholder case is not modelled as a type of notification because
* invariants would be violated and because it would model domain incorrectly. It is preferable to
* {@link com.keylesspalace.tusky.util.Either} because class hierarchy is cheaper, faster and
* more native.
*/
public abstract class NotificationViewData {
private NotificationViewData() {
}
public abstract long getViewDataId();
public abstract boolean deepEquals(NotificationViewData other);
public static final class Concrete extends NotificationViewData {
private final Notification.Type type;
private final String id;
private final TimelineAccount account;
@Nullable
private final StatusViewData.Concrete statusViewData;
@Nullable
private final Report report;
public Concrete(Notification.Type type, String id, TimelineAccount account,
@Nullable StatusViewData.Concrete statusViewData, @Nullable Report report) {
this.type = type;
this.id = id;
this.account = account;
this.statusViewData = statusViewData;
this.report = report;
}
public Notification.Type getType() {
return type;
}
public String getId() {
return id;
}
public TimelineAccount getAccount() {
return account;
}
@Nullable
public StatusViewData.Concrete getStatusViewData() {
return statusViewData;
}
@Nullable
public Report getReport() {
return report;
}
@Override
public long getViewDataId() {
return id.hashCode();
}
@Override
public boolean deepEquals(NotificationViewData o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
Concrete concrete = (Concrete) o;
return type == concrete.type &&
Objects.equals(id, concrete.id) &&
account.getId().equals(concrete.account.getId()) &&
(Objects.equals(statusViewData, concrete.statusViewData)) &&
(Objects.equals(report, concrete.report));
}
@Override
public int hashCode() {
return Objects.hash(type, id, account, statusViewData);
}
public Concrete copyWithStatus(@Nullable StatusViewData.Concrete statusViewData) {
return new Concrete(type, id, account, statusViewData, report);
}
}
public static final class Placeholder extends NotificationViewData {
private final long id;
private final boolean isLoading;
public Placeholder(long id, boolean isLoading) {
this.id = id;
this.isLoading = isLoading;
}
public boolean isLoading() {
return isLoading;
}
@Override
public long getViewDataId() {
return id;
}
@Override
public boolean deepEquals(NotificationViewData other) {
if (!(other instanceof Placeholder)) return false;
Placeholder that = (Placeholder) other;
return isLoading == that.isLoading && id == that.id;
}
}
}

View File

@ -0,0 +1,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 <http://www.gnu.org/licenses>.
*/
/*
* This file is a part of Tusky.
*
* This program is free software; you can redistribute it and/or modify it under the terms of the
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
* License, or (at your option) any later version.
*
* Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
* Public License for more details.
*
* You should have received a copy of the GNU General Public License along with Tusky; if not,
* see <http://www.gnu.org/licenses>. */
package com.keylesspalace.tusky.viewdata
import com.keylesspalace.tusky.entity.Notification
import com.keylesspalace.tusky.entity.Report
import com.keylesspalace.tusky.entity.TimelineAccount
data class NotificationViewData(
val type: Notification.Type,
val id: String,
val account: TimelineAccount,
var statusViewData: StatusViewData.Concrete?,
val report: Report?
)

View File

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

View File

@ -1,4 +1,21 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
~ Copyright 2023 Tusky Contributors
~
~ This file is a part of Tusky.
~
~ This program is free software; you can redistribute it and/or modify it under the terms of the
~ GNU General Public License as published by the Free Software Foundation; either version 3 of the
~ License, or (at your option) any later version.
~
~ Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
~ the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
~ Public License for more details.
~
~ You should have received a copy of the GNU General Public License along with Tusky; if not,
~ see <http://www.gnu.org/licenses>.
-->
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
@ -82,4 +99,4 @@
android:visibility="gone"
tools:visibility="visible" />
</androidx.coordinatorlayout.widget.CoordinatorLayout>
</FrameLayout>
</FrameLayout>

View File

@ -1,4 +1,21 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
~ Copyright 2023 Tusky Contributors
~
~ This file is a part of Tusky.
~
~ This program is free software; you can redistribute it and/or modify it under the terms of the
~ GNU General Public License as published by the Free Software Foundation; either version 3 of the
~ License, or (at your option) any later version.
~
~ Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
~ the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
~ Public License for more details.
~
~ You should have received a copy of the GNU General Public License along with Tusky; if not,
~ see <http://www.gnu.org/licenses>.
-->
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"

View File

@ -0,0 +1,45 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
~ Copyright 2023 Tusky Contributors
~
~ This file is a part of Tusky.
~
~ This program is free software; you can redistribute it and/or modify it under the terms of the
~ GNU General Public License as published by the Free Software Foundation; either version 3 of the
~ License, or (at your option) any later version.
~
~ Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
~ the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
~ Public License for more details.
~
~ You should have received a copy of the GNU General Public License along with Tusky; if not,
~ see <http://www.gnu.org/licenses>.
-->
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="8dp">
<androidx.core.widget.ContentLoadingProgressBar
android:id="@+id/progress_bar"
style="?android:attr/progressBarStyle"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
<TextView
android:id="@+id/error_msg"
android:textColor="?android:textColorPrimary"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:textAlignment="center"
tools:text="@string/socket_timeout_exception"/>
<Button
android:id="@+id/retry_button"
style="@style/TuskyButton.Outlined"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:text="@string/action_retry"/>
</LinearLayout>

View File

@ -1,19 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:orientation="vertical" android:layout_width="200dp"
android:layout_height="wrap_content"
android:background="?android:attr/windowBackground">
<ListView
android:id="@+id/listView"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1" />
<Button
android:id="@+id/buttonApply"
style="@style/TuskyButton.TextButton"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_weight="0"
android:text="@string/filter_apply"
android:textSize="?attr/status_text_medium" />
</LinearLayout>

View File

@ -0,0 +1,27 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Copyright (C) 2006 The Android Open Source Project
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<!-- Copied from platform so it will work with view binding -->
<TextView xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@android:id/text1"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textAppearance="?android:attr/textAppearanceListItemSmall"
android:gravity="center_vertical"
android:paddingStart="?android:attr/listPreferredItemPaddingStart"
android:paddingEnd="?android:attr/listPreferredItemPaddingEnd"
android:minHeight="?android:attr/listPreferredItemHeightSmall" />

View File

@ -1,4 +1,21 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
~ Copyright 2023 Tusky Contributors
~
~ This file is a part of Tusky.
~
~ This program is free software; you can redistribute it and/or modify it under the terms of the
~ GNU General Public License as published by the Free Software Foundation; either version 3 of the
~ License, or (at your option) any later version.
~
~ Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
~ the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
~ Public License for more details.
~
~ You should have received a copy of the GNU General Public License along with Tusky; if not,
~ see <http://www.gnu.org/licenses>.
-->
<menu xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<item

View File

@ -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 <http://www.gnu.org/licenses>.
-->
<resources>
<string name="error_generic">An error occurred.</string>
@ -352,6 +369,7 @@
<string name="notification_update_description">Notifications when posts you\'ve interacted with are edited</string>
<string name="notification_report_name">Reports</string>
<string name="notification_report_description">Notifications about moderation reports</string>
<string name="notification_unknown_name">Unknown</string>
<string name="notification_mention_format">%s mentioned you</string>
<string name="notification_summary_large">%1$s, %2$s, %3$s and %4$d others</string>
@ -749,4 +767,21 @@
<string name="accessibility_talking_about_tag">%1$d people are talking about hashtag %2$s</string>
<string name="total_usage">Total usage</string>
<string name="total_accounts">Total accounts</string>
<!-- User friendly error messages for different network errors -->
<string name="socket_timeout_exception">Contacting your server took too long</string>
<!-- Error messages, displayed in snackbars, when something failed -->
<string name="ui_error_unknown">unknown reason</string>
<string name="ui_error_bookmark">Bookmarking post failed: %s</string>
<string name="ui_error_clear_notifications">Clearing notifications failed: %s</string>
<string name="ui_error_favourite">Favoriting post failed: %s</string>
<string name="ui_error_reblog">Boosting post failed: %s</string>
<string name="ui_error_vote">Voting in poll failed: %s</string>
<string name="ui_error_accept_follow_request">Accepting follow request failed: %s</string>
<string name="ui_error_reject_follow_request">Rejecting follow request failed: %s</string>
<!-- Success messages, displayed in snackbars, when an action succeeded -->
<string name="ui_success_accepted_follow_request">Follow request accepted</string>
<string name="ui_success_rejected_follow_request">Follow request blocked</string>
</resources>

View File

@ -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 <http://www.gnu.org/licenses>.
*/
package com.keylesspalace.tusky
import androidx.test.ext.junit.runners.AndroidJUnit4
@ -16,7 +33,6 @@ import org.junit.runner.RunWith
import org.mockito.kotlin.mock
import org.robolectric.annotation.Config
import java.time.Instant
import java.util.ArrayList
import java.util.Date
@Config(sdk = [28])
@ -243,69 +259,71 @@ class FilterTest {
assert(updatedDuration != null && updatedDuration > (expiresInSeconds - 60))
}
private fun mockStatus(
content: String = "",
spoilerText: String = "",
pollOptions: List<String>? = null,
attachmentsDescriptions: List<String>? = null
): Status {
return Status(
id = "123",
url = "https://mastodon.social/@Tusky/100571663297225812",
account = mock(),
inReplyToId = null,
inReplyToAccountId = null,
reblog = null,
content = content,
createdAt = Date(),
editedAt = null,
emojis = emptyList(),
reblogsCount = 0,
favouritesCount = 0,
repliesCount = 0,
reblogged = false,
favourited = false,
bookmarked = false,
sensitive = false,
spoilerText = spoilerText,
visibility = Status.Visibility.PUBLIC,
attachments = if (attachmentsDescriptions != null) {
ArrayList(
attachmentsDescriptions.map {
Attachment(
id = "1234",
url = "",
previewUrl = null,
meta = null,
type = Attachment.Type.IMAGE,
description = it,
blurhash = null
)
}
)
} else arrayListOf(),
mentions = listOf(),
tags = listOf(),
application = null,
pinned = false,
muted = false,
poll = if (pollOptions != null) {
Poll(
id = "1234",
expiresAt = null,
expired = false,
multiple = false,
votesCount = 0,
votersCount = 0,
options = pollOptions.map {
PollOption(it, 0)
},
voted = false,
ownVotes = null
)
} else null,
card = null,
language = null,
)
companion object {
fun mockStatus(
content: String = "",
spoilerText: String = "",
pollOptions: List<String>? = null,
attachmentsDescriptions: List<String>? = null
): Status {
return Status(
id = "123",
url = "https://mastodon.social/@Tusky/100571663297225812",
account = mock(),
inReplyToId = null,
inReplyToAccountId = null,
reblog = null,
content = content,
createdAt = Date(),
editedAt = null,
emojis = emptyList(),
reblogsCount = 0,
favouritesCount = 0,
repliesCount = 0,
reblogged = false,
favourited = false,
bookmarked = false,
sensitive = false,
spoilerText = spoilerText,
visibility = Status.Visibility.PUBLIC,
attachments = if (attachmentsDescriptions != null) {
ArrayList(
attachmentsDescriptions.map {
Attachment(
id = "1234",
url = "",
previewUrl = null,
meta = null,
type = Attachment.Type.IMAGE,
description = it,
blurhash = null
)
}
)
} else arrayListOf(),
mentions = listOf(),
tags = listOf(),
application = null,
pinned = false,
muted = false,
poll = if (pollOptions != null) {
Poll(
id = "1234",
expiresAt = null,
expired = false,
multiple = false,
votesCount = 0,
votersCount = 0,
options = pollOptions.map {
PollOption(it, 0)
},
voted = false,
ownVotes = null
)
} else null,
card = null,
language = null,
)
}
}
}

View File

@ -0,0 +1,137 @@
/*
* Copyright 2023 Tusky Contributors
*
* This file is a part of Tusky.
*
* This program is free software; you can redistribute it and/or modify it under the terms of the
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
* License, or (at your option) any later version.
*
* Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
* Public License for more details.
*
* You should have received a copy of the GNU General Public License along with Tusky; if not,
* see <http://www.gnu.org/licenses>.
*/
package com.keylesspalace.tusky.components.notifications
import android.content.SharedPreferences
import android.os.Looper
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.keylesspalace.tusky.appstore.EventHub
import com.keylesspalace.tusky.db.AccountEntity
import com.keylesspalace.tusky.db.AccountManager
import com.keylesspalace.tusky.settings.PrefKeys
import com.keylesspalace.tusky.usecase.TimelineCases
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.TestDispatcher
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.setMain
import okhttp3.ResponseBody
import okhttp3.ResponseBody.Companion.toResponseBody
import org.junit.Before
import org.junit.Rule
import org.junit.rules.TestWatcher
import org.junit.runner.Description
import org.junit.runner.RunWith
import org.mockito.kotlin.any
import org.mockito.kotlin.doAnswer
import org.mockito.kotlin.doReturn
import org.mockito.kotlin.mock
import org.robolectric.Shadows.shadowOf
import org.robolectric.annotation.Config
import retrofit2.HttpException
import retrofit2.Response
@OptIn(ExperimentalCoroutinesApi::class)
class MainCoroutineRule constructor(private val dispatcher: TestDispatcher = UnconfinedTestDispatcher()) : TestWatcher() {
override fun starting(description: Description) {
super.starting(description)
Dispatchers.setMain(dispatcher)
}
override fun finished(description: Description) {
super.finished(description)
Dispatchers.resetMain()
}
}
@Config(sdk = [28])
@RunWith(AndroidJUnit4::class)
@OptIn(ExperimentalCoroutinesApi::class)
abstract class NotificationsViewModelTestBase {
protected lateinit var notificationsRepository: NotificationsRepository
protected lateinit var sharedPreferencesMap: MutableMap<String, Boolean>
protected lateinit var sharedPreferences: SharedPreferences
protected lateinit var accountManager: AccountManager
protected lateinit var timelineCases: TimelineCases
protected lateinit var eventHub: EventHub
protected lateinit var viewModel: NotificationsViewModel
/** Empty success response, for API calls that return one */
protected var emptySuccess = Response.success("".toResponseBody())
/** Empty error response, for API calls that return one */
protected var emptyError: Response<ResponseBody> = Response.error(404, "".toResponseBody())
/** Exception to throw when testing errors */
protected val httpException = HttpException(emptyError)
@get:Rule
val mainCoroutineRule = MainCoroutineRule()
@Before
fun setup() {
shadowOf(Looper.getMainLooper()).idle()
notificationsRepository = mock()
// Backing store for sharedPreferences, to allow mutation in tests
sharedPreferencesMap = mutableMapOf(
PrefKeys.ANIMATE_GIF_AVATARS to false,
PrefKeys.ANIMATE_CUSTOM_EMOJIS to false,
PrefKeys.ABSOLUTE_TIME_VIEW to false,
PrefKeys.SHOW_BOT_OVERLAY to true,
PrefKeys.USE_BLURHASH to true,
PrefKeys.CONFIRM_REBLOGS to true,
PrefKeys.CONFIRM_FAVOURITES to false,
PrefKeys.WELLBEING_HIDE_STATS_POSTS to false,
PrefKeys.SHOW_NOTIFICATIONS_FILTER to true,
PrefKeys.FAB_HIDE to false
)
// Any getBoolean() call looks for the result in sharedPreferencesMap
sharedPreferences = mock {
on { getBoolean(any(), any()) } doAnswer { sharedPreferencesMap[it.arguments[0]] }
}
accountManager = mock {
on { activeAccount } doReturn AccountEntity(
id = 1,
domain = "mastodon.test",
accessToken = "fakeToken",
clientId = "fakeId",
clientSecret = "fakeSecret",
isActive = true,
notificationsFilter = "['follow']",
mediaPreviewEnabled = true,
alwaysShowSensitiveMedia = true,
alwaysOpenSpoiler = true
)
}
eventHub = EventHub()
timelineCases = mock()
viewModel = NotificationsViewModel(
notificationsRepository,
sharedPreferences,
accountManager,
timelineCases,
eventHub
)
}
}

View File

@ -0,0 +1,65 @@
/*
* Copyright 2023 Tusky Contributors
*
* This file is a part of Tusky.
*
* This program is free software; you can redistribute it and/or modify it under the terms of the
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
* License, or (at your option) any later version.
*
* Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
* Public License for more details.
*
* You should have received a copy of the GNU General Public License along with Tusky; if not,
* see <http://www.gnu.org/licenses>.
*/
package com.keylesspalace.tusky.components.notifications
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest
import org.junit.Test
import org.mockito.kotlin.doReturn
import org.mockito.kotlin.stub
import org.mockito.kotlin.verify
/**
* Verify that [ClearNotifications] is handled correctly on receipt:
*
* - Is the correct [UiSuccess] or [UiError] value emitted?
* - Are the correct [NotificationsRepository] functions called, with the correct arguments?
* This is only tested in the success case; if it passed there it must also
* have passed in the error case.
*/
@OptIn(ExperimentalCoroutinesApi::class)
class NotificationsViewModelTestClearNotifications : NotificationsViewModelTestBase() {
@Test
fun `clearing notifications succeeds && invalidate the repository`() = runTest {
// Given
notificationsRepository.stub { onBlocking { clearNotifications() } doReturn emptySuccess }
// When
viewModel.accept(FallibleUiAction.ClearNotifications)
// Then
verify(notificationsRepository).clearNotifications()
verify(notificationsRepository).invalidate()
}
@Test
fun `clearing notifications fails && emits UiError`() = runTest {
// Given
notificationsRepository.stub { onBlocking { clearNotifications() } doReturn emptyError }
viewModel.uiError.test {
// When
viewModel.accept(FallibleUiAction.ClearNotifications)
// Then
assertThat(awaitItem()).isInstanceOf(UiError::class.java)
}
}
}

View File

@ -0,0 +1,66 @@
/*
* Copyright 2023 Tusky Contributors
*
* This file is a part of Tusky.
*
* This program is free software; you can redistribute it and/or modify it under the terms of the
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
* License, or (at your option) any later version.
*
* Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
* Public License for more details.
*
* You should have received a copy of the GNU General Public License along with Tusky; if not,
* see <http://www.gnu.org/licenses>.
*/
package com.keylesspalace.tusky.components.notifications
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import com.keylesspalace.tusky.db.AccountEntity
import com.keylesspalace.tusky.entity.Notification
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest
import org.junit.Test
import org.mockito.kotlin.argumentCaptor
import org.mockito.kotlin.verify
/**
* Verify that [ApplyFilter] is handled correctly on receipt:
*
* - Is the [UiState] updated correctly?
* - Are the correct [AccountManager] functions called, with the correct arguments?
*/
@OptIn(ExperimentalCoroutinesApi::class)
class NotificationsViewModelTestFilter : NotificationsViewModelTestBase() {
@Test
fun `should load initial filter from active account`() = runTest {
viewModel.uiState.test {
assertThat(awaitItem().activeFilter)
.containsExactlyElementsIn(setOf(Notification.Type.FOLLOW))
}
}
@Test
fun `should save filter to active account && update state`() = runTest {
viewModel.uiState.test {
// When
viewModel.accept(InfallibleUiAction.ApplyFilter(setOf(Notification.Type.REBLOG)))
// Then
// - filter saved to active account
argumentCaptor<AccountEntity>().apply {
verify(accountManager).saveAccount(capture())
assertThat(this.lastValue.notificationsFilter)
.isEqualTo("[\"reblog\"]")
}
// - filter updated in uiState
assertThat(expectMostRecentItem().activeFilter)
.containsExactlyElementsIn(setOf(Notification.Type.REBLOG))
}
}
}

View File

@ -0,0 +1,144 @@
/*
* Copyright 2023 Tusky Contributors
*
* This file is a part of Tusky.
*
* This program is free software; you can redistribute it and/or modify it under the terms of the
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
* License, or (at your option) any later version.
*
* Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
* Public License for more details.
*
* You should have received a copy of the GNU General Public License along with Tusky; if not,
* see <http://www.gnu.org/licenses>.
*/
package com.keylesspalace.tusky.components.notifications
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import com.keylesspalace.tusky.entity.Relationship
import io.reactivex.rxjava3.core.Single
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest
import org.junit.Test
import org.mockito.kotlin.any
import org.mockito.kotlin.argumentCaptor
import org.mockito.kotlin.doReturn
import org.mockito.kotlin.doThrow
import org.mockito.kotlin.stub
import org.mockito.kotlin.verify
/**
* Verify that [NotificationAction] are handled correctly on receipt:
*
* - Is the correct [UiSuccess] or [UiError] value emitted?
* - Is the correct [TimelineCases] function called, with the correct arguments?
* This is only tested in the success case; if it passed there it must also
* have passed in the error case.
*/
@OptIn(ExperimentalCoroutinesApi::class)
class NotificationsViewModelTestNotificationAction : NotificationsViewModelTestBase() {
/** Dummy relationship */
private val relationship = Relationship(
// Nothing special about these values, it's just to have something to return
"1234",
following = true,
followedBy = true,
blocking = false,
muting = false,
mutingNotifications = false,
requested = false,
showingReblogs = false,
subscribing = null,
blockingDomain = false,
note = null,
notifying = null
)
/** Action to accept a follow request */
private val acceptAction = NotificationAction.AcceptFollowRequest("1234")
/** Action to reject a follow request */
private val rejectAction = NotificationAction.RejectFollowRequest("1234")
@Test
fun `accepting follow request succeeds && emits UiSuccess`() = runTest {
// Given
timelineCases.stub {
onBlocking { acceptFollowRequest(any()) } doReturn Single.just(relationship)
}
viewModel.uiSuccess.test {
// When
viewModel.accept(acceptAction)
// Then
val item = awaitItem()
assertThat(item).isInstanceOf(NotificationActionSuccess::class.java)
assertThat((item as NotificationActionSuccess).action).isEqualTo(acceptAction)
}
// Then
argumentCaptor<String>().apply {
verify(timelineCases).acceptFollowRequest(capture())
assertThat(this.lastValue).isEqualTo("1234")
}
}
@Test
fun `accepting follow request fails && emits UiError`() = runTest {
// Given
timelineCases.stub { onBlocking { acceptFollowRequest(any()) } doThrow httpException }
viewModel.uiError.test {
// When
viewModel.accept(acceptAction)
// Then
val item = awaitItem()
assertThat(item).isInstanceOf(UiError.AcceptFollowRequest::class.java)
assertThat(item.action).isEqualTo(acceptAction)
}
}
@Test
fun `rejecting follow request succeeds && emits UiSuccess`() = runTest {
// Given
timelineCases.stub { onBlocking { rejectFollowRequest(any()) } doReturn Single.just(relationship) }
viewModel.uiSuccess.test {
// When
viewModel.accept(rejectAction)
// Then
val item = awaitItem()
assertThat(item).isInstanceOf(NotificationActionSuccess::class.java)
assertThat((item as NotificationActionSuccess).action).isEqualTo(rejectAction)
}
// Then
argumentCaptor<String>().apply {
verify(timelineCases).rejectFollowRequest(capture())
assertThat(this.lastValue).isEqualTo("1234")
}
}
@Test
fun `rejecting follow request fails && emits UiError`() = runTest {
// Given
timelineCases.stub { onBlocking { rejectFollowRequest(any()) } doThrow httpException }
viewModel.uiError.test {
// When
viewModel.accept(rejectAction)
// Then
val item = awaitItem()
assertThat(item).isInstanceOf(UiError.RejectFollowRequest::class.java)
assertThat(item.action).isEqualTo(rejectAction)
}
}
}

View File

@ -0,0 +1,227 @@
/*
* Copyright 2023 Tusky Contributors
*
* This file is a part of Tusky.
*
* This program is free software; you can redistribute it and/or modify it under the terms of the
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
* License, or (at your option) any later version.
*
* Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
* Public License for more details.
*
* You should have received a copy of the GNU General Public License along with Tusky; if not,
* see <http://www.gnu.org/licenses>.
*/
package com.keylesspalace.tusky.components.notifications
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import com.keylesspalace.tusky.FilterTest.Companion.mockStatus
import com.keylesspalace.tusky.viewdata.StatusViewData
import io.reactivex.rxjava3.core.Single
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest
import org.junit.Test
import org.mockito.kotlin.any
import org.mockito.kotlin.argumentCaptor
import org.mockito.kotlin.doReturn
import org.mockito.kotlin.doThrow
import org.mockito.kotlin.stub
import org.mockito.kotlin.verify
/**
* Verify that [StatusAction] are handled correctly on receipt:
*
* - Is the correct [UiSuccess] or [UiError] value emitted?
* - Is the correct [TimelineCases] function called, with the correct arguments?
* This is only tested in the success case; if it passed there it must also
* have passed in the error case.
*/
@OptIn(ExperimentalCoroutinesApi::class)
class NotificationsViewModelTestStatusAction : NotificationsViewModelTestBase() {
private val status = mockStatus(pollOptions = listOf("Choice 1", "Choice 2", "Choice 3"))
private val statusViewData = StatusViewData.Concrete(
status = status,
isExpanded = true,
isShowingContent = false,
isCollapsed = false
)
/** Action to bookmark a status */
private val bookmarkAction = StatusAction.Bookmark(true, statusViewData)
/** Action to favourite a status */
private val favouriteAction = StatusAction.Favourite(true, statusViewData)
/** Action to reblog a status */
private val reblogAction = StatusAction.Reblog(true, statusViewData)
/** Action to vote in a poll */
private val voteInPollAction = StatusAction.VoteInPoll(
poll = status.poll!!,
choices = listOf(1, 0, 0),
statusViewData
)
/** Captors for status ID and state arguments */
private val id = argumentCaptor<String>()
private val state = argumentCaptor<Boolean>()
@Test
fun `bookmark succeeds && emits UiSuccess`() = runTest {
// Given
timelineCases.stub { onBlocking { bookmark(any(), any()) } doReturn Single.just(status) }
viewModel.uiSuccess.test {
// When
viewModel.accept(bookmarkAction)
// Then
val item = awaitItem()
assertThat(item).isInstanceOf(StatusActionSuccess.Bookmark::class.java)
assertThat((item as StatusActionSuccess).action).isEqualTo(bookmarkAction)
}
// Then
verify(timelineCases).bookmark(id.capture(), state.capture())
assertThat(id.firstValue).isEqualTo(statusViewData.status.id)
assertThat(state.firstValue).isEqualTo(true)
}
@Test
fun `bookmark fails && emits UiError`() = runTest {
// Given
timelineCases.stub { onBlocking { bookmark(any(), any()) } doThrow httpException }
viewModel.uiError.test {
// When
viewModel.accept(bookmarkAction)
// Then
val item = awaitItem()
assertThat(item).isInstanceOf(UiError.Bookmark::class.java)
assertThat(item.action).isEqualTo(bookmarkAction)
}
}
@Test
fun `favourite succeeds && emits UiSuccess`() = runTest {
// Given
timelineCases.stub {
onBlocking { favourite(any(), any()) } doReturn Single.just(status)
}
viewModel.uiSuccess.test {
// When
viewModel.accept(favouriteAction)
// Then
val item = awaitItem()
assertThat(item).isInstanceOf(StatusActionSuccess.Favourite::class.java)
assertThat((item as StatusActionSuccess).action).isEqualTo(favouriteAction)
}
// Then
verify(timelineCases).favourite(id.capture(), state.capture())
assertThat(id.firstValue).isEqualTo(statusViewData.status.id)
assertThat(state.firstValue).isEqualTo(true)
}
@Test
fun `favourite fails && emits UiError`() = runTest {
// Given
timelineCases.stub { onBlocking { favourite(any(), any()) } doThrow httpException }
viewModel.uiError.test {
// When
viewModel.accept(favouriteAction)
// Then
val item = awaitItem()
assertThat(item).isInstanceOf(UiError.Favourite::class.java)
assertThat(item.action).isEqualTo(favouriteAction)
}
}
@Test
fun `reblog succeeds && emits UiSuccess`() = runTest {
// Given
timelineCases.stub { onBlocking { reblog(any(), any()) } doReturn Single.just(status) }
viewModel.uiSuccess.test {
// When
viewModel.accept(reblogAction)
// Then
val item = awaitItem()
assertThat(item).isInstanceOf(StatusActionSuccess.Reblog::class.java)
assertThat((item as StatusActionSuccess).action).isEqualTo(reblogAction)
}
// Then
verify(timelineCases).reblog(id.capture(), state.capture())
assertThat(id.firstValue).isEqualTo(statusViewData.status.id)
assertThat(state.firstValue).isEqualTo(true)
}
@Test
fun `reblog fails && emits UiError`() = runTest {
// Given
timelineCases.stub { onBlocking { reblog(any(), any()) } doThrow httpException }
viewModel.uiError.test {
// When
viewModel.accept(reblogAction)
// Then
val item = awaitItem()
assertThat(item).isInstanceOf(UiError.Reblog::class.java)
assertThat(item.action).isEqualTo(reblogAction)
}
}
@Test
fun `voteinpoll succeeds && emits UiSuccess`() = runTest {
// Given
timelineCases.stub {
onBlocking { voteInPoll(any(), any(), any()) } doReturn Single.just(status.poll!!)
}
viewModel.uiSuccess.test {
// When
viewModel.accept(voteInPollAction)
// Then
val item = awaitItem()
assertThat(item).isInstanceOf(StatusActionSuccess.VoteInPoll::class.java)
assertThat((item as StatusActionSuccess).action).isEqualTo(voteInPollAction)
}
// Then
val pollId = argumentCaptor<String>()
val choices = argumentCaptor<List<Int>>()
verify(timelineCases).voteInPoll(id.capture(), pollId.capture(), choices.capture())
assertThat(id.firstValue).isEqualTo(statusViewData.status.id)
assertThat(pollId.firstValue).isEqualTo(status.poll!!.id)
assertThat(choices.firstValue).isEqualTo(voteInPollAction.choices)
}
@Test
fun `voteinpoll fails && emits UiError`() = runTest {
// Given
timelineCases.stub { onBlocking { voteInPoll(any(), any(), any()) } doThrow httpException }
viewModel.uiError.test {
// When
viewModel.accept(voteInPollAction)
// Then
val item = awaitItem()
assertThat(item).isInstanceOf(UiError.VoteInPoll::class.java)
assertThat(item.action).isEqualTo(voteInPollAction)
}
}
}

View File

@ -0,0 +1,102 @@
/*
* Copyright 2023 Tusky Contributors
*
* This file is a part of Tusky.
*
* This program is free software; you can redistribute it and/or modify it under the terms of the
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
* License, or (at your option) any later version.
*
* Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
* Public License for more details.
*
* You should have received a copy of the GNU General Public License along with Tusky; if not,
* see <http://www.gnu.org/licenses>.
*/
package com.keylesspalace.tusky.components.notifications
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import com.keylesspalace.tusky.appstore.PreferenceChangedEvent
import com.keylesspalace.tusky.settings.PrefKeys
import com.keylesspalace.tusky.util.CardViewMode
import com.keylesspalace.tusky.util.StatusDisplayOptions
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest
import org.junit.Test
/**
* Verify that [StatusDisplayOptions] are handled correctly.
*
* - Is the initial value taken from values in sharedPreferences and account?
* - Does the make() function correctly use an updated preference?
* - Is the correct update emitted when a relevant preference changes?
*/
@OptIn(ExperimentalCoroutinesApi::class)
class NotificationsViewModelTestStatusDisplayOptions : NotificationsViewModelTestBase() {
private val defaultStatusDisplayOptions = StatusDisplayOptions(
animateAvatars = false,
mediaPreviewEnabled = true, // setting in NotificationsViewModelTestBase
useAbsoluteTime = false,
showBotOverlay = true,
useBlurhash = true,
cardViewMode = CardViewMode.NONE,
confirmReblogs = true,
confirmFavourites = false,
hideStats = false,
animateEmojis = false,
showSensitiveMedia = true, // setting in NotificationsViewModelTestBase
openSpoiler = true // setting in NotificationsViewModelTestBase
)
@Test
fun `initial settings are from sharedPreferences and activeAccount`() = runTest {
viewModel.statusDisplayOptions.test {
val item = awaitItem()
assertThat(item).isEqualTo(defaultStatusDisplayOptions)
}
}
@Test
fun `make() uses updated preference`() = runTest {
// Prior, should be false
assertThat(defaultStatusDisplayOptions.animateAvatars).isFalse()
// Given; just a change to one preferences
sharedPreferencesMap[PrefKeys.ANIMATE_GIF_AVATARS] = true
// When
val updatedOptions = defaultStatusDisplayOptions.make(
sharedPreferences,
PrefKeys.ANIMATE_GIF_AVATARS,
accountManager.activeAccount!!
)
// Then, should be true
assertThat(updatedOptions.animateAvatars).isTrue()
}
@Test
fun `PreferenceChangedEvent emits new StatusDisplayOptions`() = runTest {
// Prior, should be false
viewModel.statusDisplayOptions.test {
val item = expectMostRecentItem()
assertThat(item.animateAvatars).isFalse()
}
// Given
sharedPreferencesMap[PrefKeys.ANIMATE_GIF_AVATARS] = true
// When
eventHub.dispatch(PreferenceChangedEvent(PrefKeys.ANIMATE_GIF_AVATARS))
// Then, should be true
viewModel.statusDisplayOptions.test {
val item = expectMostRecentItem()
assertThat(item.animateAvatars).isTrue()
}
}
}

View File

@ -0,0 +1,88 @@
/*
* Copyright 2023 Tusky Contributors
*
* This file is a part of Tusky.
*
* This program is free software; you can redistribute it and/or modify it under the terms of the
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
* License, or (at your option) any later version.
*
* Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
* Public License for more details.
*
* You should have received a copy of the GNU General Public License along with Tusky; if not,
* see <http://www.gnu.org/licenses>.
*/
package com.keylesspalace.tusky.components.notifications
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import com.keylesspalace.tusky.appstore.PreferenceChangedEvent
import com.keylesspalace.tusky.entity.Notification
import com.keylesspalace.tusky.settings.PrefKeys
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest
import org.junit.Test
/**
* Verify that [UiState] is handled correctly.
*
* - Is the initial value taken from values in sharedPreferences and account?
* - Is the correct update emitted when a relevant preference changes?
*/
@OptIn(ExperimentalCoroutinesApi::class)
class NotificationsViewModelTestUiState : NotificationsViewModelTestBase() {
private val initialUiState = UiState(
activeFilter = setOf(Notification.Type.FOLLOW),
showFilterOptions = true,
showFabWhileScrolling = true
)
@Test
fun `should load initial filter from active account`() = runTest {
viewModel.uiState.test {
assertThat(expectMostRecentItem()).isEqualTo(initialUiState)
}
}
@Test
fun `showFabWhileScrolling depends on FAB_HIDE preference`() = runTest {
// Prior
viewModel.uiState.test {
assertThat(expectMostRecentItem().showFabWhileScrolling).isTrue()
}
// Given
sharedPreferencesMap[PrefKeys.FAB_HIDE] = true
// When
eventHub.dispatch(PreferenceChangedEvent(PrefKeys.FAB_HIDE))
// Then
viewModel.uiState.test {
assertThat(expectMostRecentItem().showFabWhileScrolling).isFalse()
}
}
@Test
fun `showFilterOptions depends on SHOW_NOTIFICATIONS_FILTER preference`() = runTest {
// Prior
viewModel.uiState.test {
assertThat(expectMostRecentItem().showFilterOptions).isTrue()
}
// Given
sharedPreferencesMap[PrefKeys.SHOW_NOTIFICATIONS_FILTER] = false
// When
eventHub.dispatch(PreferenceChangedEvent(PrefKeys.SHOW_NOTIFICATIONS_FILTER))
// Then
viewModel.uiState.test {
assertThat(expectMostRecentItem().showFilterOptions).isFalse()
}
}
}

View File

@ -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 <http://www.gnu.org/licenses>.
*/
package com.keylesspalace.tusky.components.notifications
import com.google.common.truth.Truth.assertThat
import com.keylesspalace.tusky.db.AccountEntity
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest
import org.junit.Test
import org.mockito.kotlin.argumentCaptor
import org.mockito.kotlin.verify
@OptIn(ExperimentalCoroutinesApi::class)
class NotificationsViewModelTestVisibleId : NotificationsViewModelTestBase() {
@Test
fun `should save notification ID to active account`() = runTest {
argumentCaptor<AccountEntity>().apply {
// When
viewModel.accept(InfallibleUiAction.SaveVisibleId("1234"))
// Then
verify(accountManager).saveAccount(capture())
assertThat(this.lastValue.lastNotificationId)
.isEqualTo("1234")
}
}
}

615
doc/ViewModelInterface.md Normal file
View File

@ -0,0 +1,615 @@
# View model interface
## Synopsis
This document explains how data flows between the view model and the UI it
is serving (either an `Activity` or `Fragment`).
> Note: At the time of writing this is correct for `NotificationsViewModel`
> and `NotificationsFragment`. Other components will be updated over time.
After reading this document you should understand:
- How user actions in the UI are communicated to the view model
- How changes in the view model are communicated to the UI
Before reading this document you should:
- Understand Kotlin flows
- Read [Guide to app architecture / UI layer](https://developer.android.com/topic/architecture/ui-layer)
## Action and UiState flows
### The basics
Every action between the user and application can be reduced to the following:
```mermaid
sequenceDiagram
actor user as User
participant ui as Fragment
participant vm as View Model
user->>+ui: Performs UI action
ui->>+vm: Sends action
vm->>-ui: Sends new UI state
ui->>ui: Updates visible UI
ui-->>-user: Observes changes
```
In this model, actions always flow from left to right. The user tells
the fragment to do something, then te fragment tells the view model to do
something.
The view model does **not** tell the fragment to do something.
State always flows from right to left. The view model tells the fragment
"Here's the new state, it up to you how to display it."
Not shown on this diagram, but implicit, is these actions are asynchronous,
and the view model may be making one or more requests to other components to
gather the data to use for the new UI state.
Rather than modelling this transfer of data as function calls, and by passing
callback functions from place to place they can be modelled as Kotlin flows
between the Fragment and View Model.
For example:
1. The View Model creates two flows and exposes them to the Fragment.
```kotlin
// In the View Model
data class UiAction(val action: String) { ... }
data class UiState(...) { ... }
val actionFlow = MutableSharedFlow<UiAction>()
val uiStateFlow = StateFlow<UiState>()
init {
// ...
viewModelScope.launch {
actionFlow
.collect {
// Do work
// ... work is complete
// Update UI state
uiStateFlow.emit(uiStatFlow.value.update { ... })
}
}
// ...
}
```
2. The fragment collects from `uiStateFlow`, and updates the visible UI,
and emits new `UiAction` objects in to `actionFlow` in response to the
user interacting with the UI.
```kotlin
// In the Fragment
fun onViewCreated(...) {
// ...
binding.button.setOnClickListener {
// Won't work, see section "Accepting user actions from the UI" for why
viewModel.actionFlow.emit(UiAction(action = "buttonClick"))
}
lifecycleScope.launch {
viewModel.uiStateFlow.collectLatest { uiState ->
updateUiWithState(uiState)
}
}
// ...
}
```
This is a good start, but it can be me significantly improved.
### Model actions with sealed classes
The prototypical example in the previous section suggested the
`UiAction` could be modelled as
```kotlin
data class UiAction(val action: String) { ... }
```
This is not great.
- It's stringly-typed, with opportunity for run time errors
- Trying to store all possible UI actions in a single type will lead
to a plethora of different properties, only some of which are valid
for a given action.
These problems can be solved by making `UiAction` a sealed class, and
defining subclasses, one per action.
In the case of `NotificationsFragment` the actions the user can take in
the UI are:
- Apply a filter to the set of notifications
- Clear the current set of notifications
- Save the ID of the currently visible notification in the list
> NOTE: The user can also interact with items in the list of the
> notifications.
>
> That is handled a little differently because of how code outside
> `NotificationsFragment` is currently written. It will be adjusted at
> a later time.
That becomes:
```kotlin
// In the View Model
sealed class UiAction {
data class ApplyFilter(val filter: Set<Filter>) : UiAction()
object ClearNotifications : UiAction()
data class SaveVisibleId(val visibleId: String) : UiAction()
}
```
This has multiple benefits:
- The actions the view model can act on are defined in a single place
- Each action clearly describes the information it carries with it
- Each action is strongly typed; it is impossible to create an action
of the wrong type
- As a sealed class, using the `when` statement to process actions gives
us compile-time guarantees all actions are handled
In addition, the view model can spawn multiple coroutines to process
the different actions, by filtering out actions dependent on their type,
and using other convenience methods on flows. For example:
```kotlin
// In the View Model
val actionFlow = MutableSharedFlow<UiAction>() // As before
init {
// ...
handleApplyFilter()
handleClearNotifications()
handleSaveVisibleId()
// ...
}
fun handleApplyFilter() = viewModelScope.launch {
actionFlow
.filterIsInstance<UiAction.ApplyFilter>()
.distinctUntilChanged()
.collect { action ->
// Apply the filter, update state
}
}
fun handleClearNotifications() = viewModelScope.launch {
actionFlow
.filterIsInstance<UiAction.ClearNotifications>()
.distinctUntilChanged()
.collect { action ->
// Clear notifications, update state
}
}
fun handleSaveVisibleId() = viewModelScope.launch {
actionFlow
.filterIsInstance<UiAction.SaveVisibleId>()
.distinctUntilChanged()
.collect { action ->
// Save the ID, no need to update state
}
}
```
Each of those runs in separate coroutines and ignores duplicate events.
### Accepting user actions from the UI
Example code earlier had this snippet, which does not work.
```kotlin
// In the Fragment
binding.button.setOnClickListener {
// Won't work, see section "Accepting user actions from the UI" for why
viewModel.actionFlow.emit(UiAction(action = "buttonClick"))
}
```
This fails because `emit()` is a `suspend fun`, so it must be called from a
coroutine scope.
To fix this, provide a function or property in the view model that accepts
`UiAction` and emits them in `actionFlow` under the view model's scope.
```kotlin
// In the View Model
val accept: (UiAction) -> Unit = { action ->
viewModelScope.launch { actionFlow.emit(action)}
}
```
When the Fragment wants to send a `UiAction` to the view model it:
```kotlin
// In the Fragment
binding.button.setOnClickListener {
viewModel.accept(UiAction.ClearNotifications)
}
```
### Model the difference between fallible and infallible actions
An infallible action either cannot fail, or, can fail but there are no
user-visible changes to the UI.
Conversely, a fallible action can fail and the user should be notified.
I've found it helpful to distinguish between the two at the type level, as
it simplifies error handling in the Fragment.
So the actions in `NotificationFragment` are modelled as:
```kotlin
// In the View Model
sealed class UiAction
sealed class FallibleUiAction : UiAction() {
// Actions that can fail are modelled here
// ...
}
sealed class InfallibleUiAction : UiAction() {
// Actions that cannot fail are modelled here
// ...
}
```
### Additional `UiAction` subclasses
It can be useful to have a deeper `UiAction` class hierarchy, as filtering
flows by the class of item in the flow is straightforward.
`NotificationsViewModel` splits the fallible actions the user can take as
operating on three different parts of the UI:
- Everything not the list of notifications
- Notifications in the list of notifications
- Statuses in the list of notifications
Those last two are modelled as:
```kotlin
// In the View Model
sealed class NotificationAction : FallibleUiAction() {
// subclasses here
}
sealed class StatusAction(
open val statusViewData: StatusViewData.Concrete
) : FallibleUiAction() {
// subclasses here
}
```
Separate handling for actions on notifications and statuses is then achieved
with code like:
```kotlin
viewModelScope.launch {
uiAction.filterIsInstance<NotificationAction>()
.collect { action ->
// Process notification actions here
}
}
viewModelScope.launch {
uiAction.filterIsInstance<StatusAction>()
.collect { action ->
// Process status actions where
}
}
```
At the time of writing the UI action hierarchy for `NotificationsViewModel`
is:
```mermaid
classDiagram
direction LR
UiAction <|-- InfallibleUiAction
InfallibleUiAction <|-- SaveVisibleId
InfallibleUiAction <|-- ApplyFilter
UiAction <|-- FallibleUiAction
FallibleUiAction <|-- ClearNotifications
FallibleUiAction <|-- NotificationAction
NotificationAction <|-- AcceptFollowRequest
NotificationAction <|-- RejectFollowRequest
FallibleUiAction <|-- StatusAction
StatusAction <|-- Bookmark
StatusAction <|-- Favourite
StatusAction <|-- Reblog
StatusAction <|-- VoteInPoll
```
### Multiple output flows
So far the UI has been modelled as a single output flow of a single `UiState`
type.
For simple UIs that can be sufficient. As the UI gets more complex it
can be helpful to separate these in to different flows.
In some cases the Android framework requires you to do this. For
example, the flow of `PagingData` in to the adapter is provided and
managed by the `PagingData` class. You should not attempt to reassign
it or update it during normal operation.
Similarly, `RecyclerView.Adapter` provides its own `loadStateFlow`, which
communicates information about the loading state of data in to the adapter.
For `NotificationsViewModel` I have found it helpful to provide flows to
separate the following types
- `PagingData` in to the adapter
- `UiState`, representing UI state *outside* the main `RecyclerView`
- `StatusDisplayOptions`, representing the user's preferences for how
all statuses should be displayed
- `UiSuccess`, representing transient notifications about a
fallible action succeeding
- `UiError`, representing transient notifications about a fallible action
failing
There are separated this way to roughly match how the Fragment will want
to process them.
- `PagingData` is handed to the adapter and not modified by the Fragment
- `UiState` is generally updated no matter what has changed.
- `StatusDisplayOptions` is handled by rebinding all visible items in
the list, without disturbing the rest of the UI
- `UiSuccess` show a brief snackbar without disturbing the rest
of the UI
- `UiError` show a fixed snackbar with a "Retry" option
They also have different statefulness requirements, which makes separating
them in to different flows a sensible approach.
`PagingData`, `UiState`, and `StatusDisplayOptions` are stateful -- if the
Fragment disconnects from the flow and then reconnects (e.g., because of a
configuration change) the Fragment should receive the most recent state of
each of these.
`UiSuccess` and `UiError` are not stateful. The success and error messages are
transient; if one has been shown, and there is a subsequent configuration
change the user should not see the success or error message again.
### Modelling success and failure for fallible actions
A fallible action should have models capturing success and failure
information, and be communicated to the UI.
> Note: Infallible actions, by definition, neither succeed or fail, so
> there is no need to model those states for them.
Suppose the user has clicked on the "bookmark" button on a status,
sending a `UiAction.FallibleAction.StatusAction.Bookmark(...)` to the
view model.
The view model processes the action, and is successful.
To signal this back to the UI it emits a `UiSuccess` subclass for the action's
type in to the `uiSuccess` flow, and includes the original action request.
You can read this as the `action` in the `UiAction` is a message from the
Fragment saying "Here is the action I want to be performed" and the `action`
in `UiSuccess` is the View Model saying "Here is the action that was carried
out."
Unsurprisingly, this is modelled with a `UiSuccess` class, and per-action
subclasses.
Failures are modelled similarly, with a `UiError` class. However, details
about the error are included, as well as the original action.
So each fallible action has three associated classes; one for the action,
one to represent the action succeeding, and one to represent the action
failing.
For the single "bookmark a status" action the code for its three classes
looks like this:
```kotlin
// In the View Model
sealed class StatusAction(
open val statusViewData: StatusViewData.Concrete
) : FallibleUiAction() {
data class Bookmark(
val state: Boolean,
override val statusViewData: StatusViewData.Concrete
) : StatusAction(statusViewData)
// ... other actions here
}
sealed class StatusActionSuccess(open val action: StatusAction) : UiSuccess () {
data class Bookmark(override val action: StatusAction.Bookmark) :
StatusActionSuccess(action)
// ... other action successes here
companion object {
fun from (action: StatusAction) = when (action) {
is StatusAction.Bookmark -> Bookmark(action)
// ... other actions here
}
}
}
sealed class UiError(
open val exception: Exception,
@StringRes val message: Int,
open val action: UiAction? = null
) {
data class Bookmark(
override val exception: Exception,
override val action: StatusAction.Bookmark
) : UiError(exception, R.string.ui_error_bookmark, action)
// ... other action errors here
companion object {
fun make(exception: Exception, action: FallibleUiAction) = when (action) {
is StatusAction.Bookmark -> Bookmark(exception, action)
// other actions here
}
}
}
```
> Note: I haven't found it necessary to create subclasses for `UiError`, as
> all fallible errors (so far) are handled identically. This may change in
> the future.
Receiving status actions in the view model (from the `uiAction` flow) is then:
```kotlin
// In the View Model
viewModelScope.launch {
uiAction.filterIsInstance<StatusAction>()
.collect { action ->
try {
when (action) {
is StatusAction.Bookmark -> {
// Process the request
}
// Other action types handled here
}
uiSuccess.emit(StatusActionSuccess.from(action))
} catch (e: Exception) {
uiError.emit(UiError.make(e, action))
}
}
}
```
Basic success handling in the fragment would be:
```kotlin
// In the Fragment
lifecycleScope.launch {
// Show a generic message when an action succeeds
this.launch {
viewModel.uiSuccess.collect {
Snackbar.make(binding.root, "Success!", LENGTH_SHORT).show()
}
}
}
```
In practice it is more complicated, with different actions depending on the
type of success.
Basic error handling in the fragment would be:
```kotlin
lifecycleScope.launch {
// Show a specific error when an action fails
this.launch {
viewModel.uiError.collect { error ->
SnackBar.make(
binding.root,
getString(error.message),
LENGTH_LONG
).show()
}
}
}
```
### Supporting "retry" semantics
This approach has an extremely helpful benefit. By including the original
action in the `UiError` response, implementing a "retry" function is as
simple as re-sending the original action (included in the error) back to
the view model.
```kotlin
lifecycleScope.launch {
// Show a specific error when an action fails. Provide a "Retry" option
// on the snackbar, and re-send the original action to retry.
this.launch {
viewModel.uiError.collect { error ->
val snackbar = SnackBar.make(
binding.root,
getString(error.message),
LENGTH_LONG
)
error.action?.let { action ->
snackbar.setAction("Retry") { viewModel.accept(action) }
}
snackbar.show()
}
}
}
```
### Updated sequence diagram
```mermaid
sequenceDiagram
actor user as User
participant ui as Fragment
participant vm as View Model
user->>ui: Performs UI action
activate ui
ui->>+vm: viewModel.accept(UiAction.*())
deactivate ui
vm->>vm: Perform action
alt Update UI state?
vm->>vm: emit(UiState(...))
vm-->>ui: UiState(...)
activate ui
ui->>ui: collect UiState, update UI
deactivate ui
else Update StatusDisplayOptions?
vm->>vm: emit(StatusDisplayOptions(...))
vm-->>ui: StatusDisplayOption(...)
activate ui
ui->>ui: collect StatusDisplayOptions, rebind list items
deactivate ui
else Successful fallible action
vm->>vm: emit(UiSuccess(...))
vm-->>ui: UiSuccess(...)
activate ui
ui->>ui: collect UiSuccess, show snackbar
deactivate ui
else Failed fallible action
vm->>vm: emit(UiError(...))
vm-->>ui: UiError(...)
activate ui
deactivate vm
ui->>ui: collect UiError, show snackbar with retry
deactivate ui
user->>ui: Presses "Retry"
activate ui
ui->>vm: viewModel.accept(error.action)
deactivate ui
activate vm
vm->>vm: Perform action, emit response...
deactivate vm
end
note over ui,vm: Type of UI change depends on type of object emitted<br>UiState, StatusDisplayOptions, UiSuccess, UiError
ui-->>user: Observes changes
```

View File

@ -47,6 +47,8 @@ rxjava3 = "3.1.6"
rxkotlin3 = "3.0.1"
photoview = "2.3.0"
sparkbutton = "4.1.0"
truth = "1.1.3"
turbine = "0.12.1"
unified-push = "2.1.1"
[plugins]
@ -129,6 +131,8 @@ rxjava3-android = { module = "io.reactivex.rxjava3:rxandroid", version.ref = "rx
rxjava3-core = { module = "io.reactivex.rxjava3:rxjava", version.ref = "rxjava3" }
rxjava3-kotlin = { module = "io.reactivex.rxjava3:rxkotlin", version.ref = "rxkotlin3" }
sparkbutton = { module = "com.github.connyduck:sparkbutton", version.ref = "sparkbutton" }
truth = { module = "com.google.truth:truth", version.ref = "truth" }
turbine = { module = "app.cash.turbine:turbine", version.ref = "turbine" }
unified-push = { module = "com.github.UnifiedPush:android-connector", version.ref = "unified-push" }
[bundles]