diff --git a/app/build.gradle b/app/build.gradle index d1259f72e..2ccfcebf2 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -167,6 +167,8 @@ dependencies { testImplementation libs.androidx.core.testing testImplementation libs.kotlinx.coroutines.test testImplementation libs.androidx.work.testing + testImplementation libs.truth + testImplementation libs.turbine androidTestImplementation libs.espresso.core androidTestImplementation libs.androidx.room.testing diff --git a/app/src/main/java/com/keylesspalace/tusky/TabData.kt b/app/src/main/java/com/keylesspalace/tusky/TabData.kt index 43e595004..09d1f1cf4 100644 --- a/app/src/main/java/com/keylesspalace/tusky/TabData.kt +++ b/app/src/main/java/com/keylesspalace/tusky/TabData.kt @@ -20,10 +20,10 @@ import androidx.annotation.DrawableRes import androidx.annotation.StringRes import androidx.fragment.app.Fragment import com.keylesspalace.tusky.components.conversation.ConversationsFragment +import com.keylesspalace.tusky.components.notifications.NotificationsFragment import com.keylesspalace.tusky.components.timeline.TimelineFragment import com.keylesspalace.tusky.components.timeline.viewmodel.TimelineViewModel import com.keylesspalace.tusky.components.trending.TrendingFragment -import com.keylesspalace.tusky.fragment.NotificationsFragment import java.util.Objects /** this would be a good case for a sealed class, but that does not work nice with Room */ diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/FollowRequestViewHolder.kt b/app/src/main/java/com/keylesspalace/tusky/adapter/FollowRequestViewHolder.kt index 7e675de33..b12b71705 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/FollowRequestViewHolder.kt +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/FollowRequestViewHolder.kt @@ -21,18 +21,41 @@ import android.text.Spanned import android.text.style.StyleSpan import androidx.recyclerview.widget.RecyclerView import com.keylesspalace.tusky.R +import com.keylesspalace.tusky.components.notifications.NotificationsPagingAdapter import com.keylesspalace.tusky.databinding.ItemFollowRequestBinding import com.keylesspalace.tusky.entity.TimelineAccount import com.keylesspalace.tusky.interfaces.AccountActionListener +import com.keylesspalace.tusky.util.StatusDisplayOptions import com.keylesspalace.tusky.util.emojify import com.keylesspalace.tusky.util.loadAvatar import com.keylesspalace.tusky.util.unicodeWrap import com.keylesspalace.tusky.util.visible +import com.keylesspalace.tusky.viewdata.NotificationViewData class FollowRequestViewHolder( private val binding: ItemFollowRequestBinding, + private val accountActionListener: AccountActionListener, private val showHeader: Boolean -) : RecyclerView.ViewHolder(binding.root) { +) : NotificationsPagingAdapter.ViewHolder, RecyclerView.ViewHolder(binding.root) { + + override fun bind( + viewData: NotificationViewData, + payloads: List<*>?, + statusDisplayOptions: StatusDisplayOptions + ) { + // Skip updates with payloads. That indicates a timestamp update, and + // this view does not have timestamps. + if (!payloads.isNullOrEmpty()) return + + setupWithAccount( + viewData.account, + statusDisplayOptions.animateAvatars, + statusDisplayOptions.animateEmojis, + statusDisplayOptions.showBotOverlay + ) + + setupActionListener(accountActionListener, viewData.account.id) + } fun setupWithAccount( account: TimelineAccount, @@ -41,18 +64,32 @@ class FollowRequestViewHolder( showBotOverlay: Boolean ) { val wrappedName = account.name.unicodeWrap() - val emojifiedName: CharSequence = wrappedName.emojify(account.emojis, itemView, animateEmojis) + val emojifiedName: CharSequence = wrappedName.emojify( + account.emojis, + itemView, + animateEmojis + ) binding.displayNameTextView.text = emojifiedName if (showHeader) { - val wholeMessage: String = itemView.context.getString(R.string.notification_follow_request_format, wrappedName) + val wholeMessage: String = itemView.context.getString( + R.string.notification_follow_request_format, + wrappedName + ) binding.notificationTextView.text = SpannableStringBuilder(wholeMessage).apply { - setSpan(StyleSpan(Typeface.BOLD), 0, wrappedName.length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) + setSpan( + StyleSpan(Typeface.BOLD), + 0, + wrappedName.length, + Spanned.SPAN_EXCLUSIVE_EXCLUSIVE + ) }.emojify(account.emojis, itemView, animateEmojis) } binding.notificationTextView.visible(showHeader) val formattedUsername = itemView.context.getString(R.string.post_username_format, account.username) binding.usernameTextView.text = formattedUsername - val avatarRadius = binding.avatar.context.resources.getDimensionPixelSize(R.dimen.avatar_radius_48dp) + val avatarRadius = binding.avatar.context.resources.getDimensionPixelSize( + R.dimen.avatar_radius_48dp + ) loadAvatar(account.avatar, binding.avatar, avatarRadius, animateAvatar) binding.avatarBadge.visible(showBotOverlay && account.bot) } diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/NotificationsAdapter.java b/app/src/main/java/com/keylesspalace/tusky/adapter/NotificationsAdapter.java deleted file mode 100644 index 87096232b..000000000 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/NotificationsAdapter.java +++ /dev/null @@ -1,691 +0,0 @@ -/* Copyright 2021 Tusky Contributors - * - * This file is a part of Tusky. - * - * This program is free software; you can redistribute it and/or modify it under the terms of the - * GNU General Public License as published by the Free Software Foundation; either version 3 of the - * License, or (at your option) any later version. - * - * Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even - * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General - * Public License for more details. - * - * You should have received a copy of the GNU General Public License along with Tusky; if not, - * see . */ - -package com.keylesspalace.tusky.adapter; - -import android.content.Context; -import android.graphics.Color; -import android.graphics.PorterDuff; -import android.graphics.Typeface; -import android.graphics.drawable.Drawable; -import android.text.InputFilter; -import android.text.SpannableStringBuilder; -import android.text.Spanned; -import android.text.TextUtils; -import android.text.style.StyleSpan; -import android.view.LayoutInflater; -import android.view.View; -import android.view.ViewGroup; -import android.widget.Button; -import android.widget.ImageView; -import android.widget.TextView; - -import androidx.annotation.ColorRes; -import androidx.annotation.DrawableRes; -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.core.content.ContextCompat; -import androidx.recyclerview.widget.RecyclerView; - -import com.bumptech.glide.Glide; -import com.keylesspalace.tusky.R; -import com.keylesspalace.tusky.databinding.ItemFollowRequestBinding; -import com.keylesspalace.tusky.databinding.ItemReportNotificationBinding; -import com.keylesspalace.tusky.entity.Emoji; -import com.keylesspalace.tusky.entity.Notification; -import com.keylesspalace.tusky.entity.Status; -import com.keylesspalace.tusky.entity.TimelineAccount; -import com.keylesspalace.tusky.interfaces.AccountActionListener; -import com.keylesspalace.tusky.interfaces.LinkListener; -import com.keylesspalace.tusky.interfaces.StatusActionListener; -import com.keylesspalace.tusky.util.AbsoluteTimeFormatter; -import com.keylesspalace.tusky.util.CardViewMode; -import com.keylesspalace.tusky.util.CustomEmojiHelper; -import com.keylesspalace.tusky.util.ImageLoadingHelper; -import com.keylesspalace.tusky.util.LinkHelper; -import com.keylesspalace.tusky.util.SmartLengthInputFilter; -import com.keylesspalace.tusky.util.StatusDisplayOptions; -import com.keylesspalace.tusky.util.StringUtils; -import com.keylesspalace.tusky.util.TimestampUtils; -import com.keylesspalace.tusky.viewdata.NotificationViewData; -import com.keylesspalace.tusky.viewdata.StatusViewData; - -import java.util.Date; -import java.util.List; - -import at.connyduck.sparkbutton.helpers.Utils; - -public class NotificationsAdapter extends RecyclerView.Adapter { - - public interface AdapterDataSource { - int getItemCount(); - - T getItemAt(int pos); - } - - - private static final int VIEW_TYPE_STATUS = 0; - private static final int VIEW_TYPE_STATUS_NOTIFICATION = 1; - private static final int VIEW_TYPE_FOLLOW = 2; - private static final int VIEW_TYPE_FOLLOW_REQUEST = 3; - private static final int VIEW_TYPE_PLACEHOLDER = 4; - private static final int VIEW_TYPE_REPORT = 5; - private static final int VIEW_TYPE_UNKNOWN = 6; - - private static final InputFilter[] COLLAPSE_INPUT_FILTER = new InputFilter[]{SmartLengthInputFilter.INSTANCE}; - private static final InputFilter[] NO_INPUT_FILTER = new InputFilter[0]; - - private final String accountId; - private StatusDisplayOptions statusDisplayOptions; - private final StatusActionListener statusListener; - private final NotificationActionListener notificationActionListener; - private final AccountActionListener accountActionListener; - private final AdapterDataSource dataSource; - private final AbsoluteTimeFormatter absoluteTimeFormatter = new AbsoluteTimeFormatter(); - - public NotificationsAdapter(String accountId, - AdapterDataSource dataSource, - StatusDisplayOptions statusDisplayOptions, - StatusActionListener statusListener, - NotificationActionListener notificationActionListener, - AccountActionListener accountActionListener) { - - this.accountId = accountId; - this.dataSource = dataSource; - this.statusDisplayOptions = statusDisplayOptions; - this.statusListener = statusListener; - this.notificationActionListener = notificationActionListener; - this.accountActionListener = accountActionListener; - } - - @NonNull - @Override - public RecyclerView.ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { - LayoutInflater inflater = LayoutInflater.from(parent.getContext()); - switch (viewType) { - case VIEW_TYPE_STATUS: { - View view = inflater - .inflate(R.layout.item_status, parent, false); - return new StatusViewHolder(view); - } - case VIEW_TYPE_STATUS_NOTIFICATION: { - View view = inflater - .inflate(R.layout.item_status_notification, parent, false); - return new StatusNotificationViewHolder(view, statusDisplayOptions, absoluteTimeFormatter); - } - case VIEW_TYPE_FOLLOW: { - View view = inflater - .inflate(R.layout.item_follow, parent, false); - return new FollowViewHolder(view, statusDisplayOptions); - } - case VIEW_TYPE_FOLLOW_REQUEST: { - ItemFollowRequestBinding binding = ItemFollowRequestBinding.inflate(inflater, parent, false); - return new FollowRequestViewHolder(binding, true); - } - case VIEW_TYPE_PLACEHOLDER: { - View view = inflater - .inflate(R.layout.item_status_placeholder, parent, false); - return new PlaceholderViewHolder(view); - } - case VIEW_TYPE_REPORT: { - ItemReportNotificationBinding binding = ItemReportNotificationBinding.inflate(inflater, parent, false); - return new ReportNotificationViewHolder(binding); - } - default: - case VIEW_TYPE_UNKNOWN: { - View view = new View(parent.getContext()); - view.setLayoutParams( - new ViewGroup.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - Utils.dpToPx(parent.getContext(), 24) - ) - ); - return new RecyclerView.ViewHolder(view) { - }; - } - } - } - - @Override - public void onBindViewHolder(@NonNull RecyclerView.ViewHolder viewHolder, int position) { - bindViewHolder(viewHolder, position, null); - } - - @Override - public void onBindViewHolder(@NonNull RecyclerView.ViewHolder viewHolder, int position, @NonNull List payloads) { - bindViewHolder(viewHolder, position, payloads); - } - - private void bindViewHolder(@NonNull RecyclerView.ViewHolder viewHolder, int position, @Nullable List payloads) { - Object payloadForHolder = payloads != null && !payloads.isEmpty() ? payloads.get(0) : null; - if (position < this.dataSource.getItemCount()) { - NotificationViewData notification = dataSource.getItemAt(position); - if (notification instanceof NotificationViewData.Placeholder) { - if (payloadForHolder == null) { - NotificationViewData.Placeholder placeholder = ((NotificationViewData.Placeholder) notification); - PlaceholderViewHolder holder = (PlaceholderViewHolder) viewHolder; - holder.setup(statusListener, placeholder.isLoading()); - } - return; - } - NotificationViewData.Concrete concreteNotification = - (NotificationViewData.Concrete) notification; - switch (viewHolder.getItemViewType()) { - case VIEW_TYPE_STATUS: { - StatusViewHolder holder = (StatusViewHolder) viewHolder; - StatusViewData.Concrete status = concreteNotification.getStatusViewData(); - if (status == null) { - /* in some very rare cases servers sends null status even though they should not, - * we have to handle it somehow */ - holder.showStatusContent(false); - } else { - if (payloads == null) { - holder.showStatusContent(true); - } - holder.setupWithStatus(status, statusListener, statusDisplayOptions, payloadForHolder); - } - if (concreteNotification.getType() == Notification.Type.POLL) { - holder.setPollInfo(accountId.equals(concreteNotification.getAccount().getId())); - } else { - holder.hideStatusInfo(); - } - break; - } - case VIEW_TYPE_STATUS_NOTIFICATION: { - StatusNotificationViewHolder holder = (StatusNotificationViewHolder) viewHolder; - StatusViewData.Concrete statusViewData = concreteNotification.getStatusViewData(); - if (payloadForHolder == null) { - if (statusViewData == null) { - /* in some very rare cases servers sends null status even though they should not, - * we have to handle it somehow */ - holder.showNotificationContent(false); - } else { - holder.showNotificationContent(true); - - Status status = statusViewData.getActionable(); - holder.setDisplayName(status.getAccount().getDisplayName(), status.getAccount().getEmojis()); - holder.setUsername(status.getAccount().getUsername()); - holder.setCreatedAt(status.getCreatedAt()); - - if (concreteNotification.getType() == Notification.Type.STATUS || - concreteNotification.getType() == Notification.Type.UPDATE) { - holder.setAvatar(status.getAccount().getAvatar(), status.getAccount().getBot()); - } else { - holder.setAvatars(status.getAccount().getAvatar(), - concreteNotification.getAccount().getAvatar()); - } - } - - holder.setMessage(concreteNotification, statusListener); - holder.setupButtons(notificationActionListener, - concreteNotification.getAccount().getId(), - concreteNotification.getId()); - } else { - if (payloadForHolder instanceof List) - for (Object item : (List) payloadForHolder) { - if (StatusBaseViewHolder.Key.KEY_CREATED.equals(item) && statusViewData != null) { - holder.setCreatedAt(statusViewData.getStatus().getActionableStatus().getCreatedAt()); - } - } - } - break; - } - case VIEW_TYPE_FOLLOW: { - if (payloadForHolder == null) { - FollowViewHolder holder = (FollowViewHolder) viewHolder; - holder.setMessage(concreteNotification.getAccount(), concreteNotification.getType() == Notification.Type.SIGN_UP); - holder.setupButtons(notificationActionListener, concreteNotification.getAccount().getId()); - } - break; - } - case VIEW_TYPE_FOLLOW_REQUEST: { - if (payloadForHolder == null) { - FollowRequestViewHolder holder = (FollowRequestViewHolder) viewHolder; - holder.setupWithAccount(concreteNotification.getAccount(), statusDisplayOptions.animateAvatars(), statusDisplayOptions.animateEmojis(), statusDisplayOptions.showBotOverlay()); - holder.setupActionListener(accountActionListener, concreteNotification.getAccount().getId()); - } - break; - } - case VIEW_TYPE_REPORT: { - if (payloadForHolder == null) { - ReportNotificationViewHolder holder = (ReportNotificationViewHolder) viewHolder; - holder.setupWithReport(concreteNotification.getAccount(), concreteNotification.getReport(), statusDisplayOptions.animateAvatars(), statusDisplayOptions.animateEmojis()); - holder.setupActionListener(notificationActionListener, concreteNotification.getReport().getTargetAccount().getId(), concreteNotification.getAccount().getId(), concreteNotification.getReport().getId()); - } - } - default: - } - } - } - - @Override - public int getItemCount() { - return dataSource.getItemCount(); - } - - public void setMediaPreviewEnabled(boolean mediaPreviewEnabled) { - this.statusDisplayOptions = statusDisplayOptions.copy( - statusDisplayOptions.animateAvatars(), - mediaPreviewEnabled, - statusDisplayOptions.useAbsoluteTime(), - statusDisplayOptions.showBotOverlay(), - statusDisplayOptions.useBlurhash(), - CardViewMode.NONE, - statusDisplayOptions.confirmReblogs(), - statusDisplayOptions.confirmFavourites(), - statusDisplayOptions.hideStats(), - statusDisplayOptions.animateEmojis() - ); - } - - public boolean isMediaPreviewEnabled() { - return this.statusDisplayOptions.mediaPreviewEnabled(); - } - - @Override - public int getItemViewType(int position) { - NotificationViewData notification = dataSource.getItemAt(position); - if (notification instanceof NotificationViewData.Concrete) { - NotificationViewData.Concrete concrete = ((NotificationViewData.Concrete) notification); - switch (concrete.getType()) { - case MENTION: - case POLL: { - return VIEW_TYPE_STATUS; - } - case STATUS: - case FAVOURITE: - case REBLOG: - case UPDATE: { - return VIEW_TYPE_STATUS_NOTIFICATION; - } - case FOLLOW: - case SIGN_UP: { - return VIEW_TYPE_FOLLOW; - } - case FOLLOW_REQUEST: { - return VIEW_TYPE_FOLLOW_REQUEST; - } - case REPORT: { - return VIEW_TYPE_REPORT; - } - default: { - return VIEW_TYPE_UNKNOWN; - } - } - } else if (notification instanceof NotificationViewData.Placeholder) { - return VIEW_TYPE_PLACEHOLDER; - } else { - throw new AssertionError("Unknown notification type"); - } - - - } - - public interface NotificationActionListener { - void onViewAccount(String id); - - void onViewStatusForNotificationId(String notificationId); - - void onViewReport(String reportId); - - void onExpandedChange(boolean expanded, int position); - - /** - * Called when the status {@link android.widget.ToggleButton} responsible for collapsing long - * status content is interacted with. - * - * @param isCollapsed Whether the status content is shown in a collapsed state or fully. - * @param position The position of the status in the list. - */ - void onNotificationContentCollapsedChange(boolean isCollapsed, int position); - } - - private static class FollowViewHolder extends RecyclerView.ViewHolder { - private final TextView message; - private final TextView usernameView; - private final TextView displayNameView; - private final ImageView avatar; - private final StatusDisplayOptions statusDisplayOptions; - - FollowViewHolder(View itemView, StatusDisplayOptions statusDisplayOptions) { - super(itemView); - message = itemView.findViewById(R.id.notification_text); - usernameView = itemView.findViewById(R.id.notification_username); - displayNameView = itemView.findViewById(R.id.notification_display_name); - avatar = itemView.findViewById(R.id.notification_avatar); - this.statusDisplayOptions = statusDisplayOptions; - } - - void setMessage(TimelineAccount account, Boolean isSignUp) { - Context context = message.getContext(); - - String format = context.getString(isSignUp ? R.string.notification_sign_up_format : R.string.notification_follow_format); - String wrappedDisplayName = StringUtils.unicodeWrap(account.getName()); - String wholeMessage = String.format(format, wrappedDisplayName); - CharSequence emojifiedMessage = CustomEmojiHelper.emojify( - wholeMessage, account.getEmojis(), message, statusDisplayOptions.animateEmojis() - ); - message.setText(emojifiedMessage); - - String username = context.getString(R.string.post_username_format, account.getUsername()); - usernameView.setText(username); - - CharSequence emojifiedDisplayName = CustomEmojiHelper.emojify( - wrappedDisplayName, account.getEmojis(), usernameView, statusDisplayOptions.animateEmojis() - ); - - displayNameView.setText(emojifiedDisplayName); - - int avatarRadius = avatar.getContext().getResources() - .getDimensionPixelSize(R.dimen.avatar_radius_42dp); - - ImageLoadingHelper.loadAvatar(account.getAvatar(), avatar, avatarRadius, - statusDisplayOptions.animateAvatars()); - - } - - void setupButtons(final NotificationActionListener listener, final String accountId) { - itemView.setOnClickListener(v -> listener.onViewAccount(accountId)); - } - } - - private static class StatusNotificationViewHolder extends RecyclerView.ViewHolder - implements View.OnClickListener { - private final TextView message; - private final View statusNameBar; - private final TextView displayName; - private final TextView username; - private final TextView timestampInfo; - private final TextView statusContent; - private final ImageView statusAvatar; - private final ImageView notificationAvatar; - private final TextView contentWarningDescriptionTextView; - private final Button contentWarningButton; - private final Button contentCollapseButton; // TODO: This code SHOULD be based on StatusBaseViewHolder - private final StatusDisplayOptions statusDisplayOptions; - private final AbsoluteTimeFormatter absoluteTimeFormatter; - - private String accountId; - private String notificationId; - private NotificationActionListener notificationActionListener; - private StatusViewData.Concrete statusViewData; - - private final int avatarRadius48dp; - private final int avatarRadius36dp; - private final int avatarRadius24dp; - - StatusNotificationViewHolder( - View itemView, - StatusDisplayOptions statusDisplayOptions, - AbsoluteTimeFormatter absoluteTimeFormatter - ) { - super(itemView); - message = itemView.findViewById(R.id.notification_top_text); - statusNameBar = itemView.findViewById(R.id.status_name_bar); - displayName = itemView.findViewById(R.id.status_display_name); - username = itemView.findViewById(R.id.status_username); - timestampInfo = itemView.findViewById(R.id.status_meta_info); - statusContent = itemView.findViewById(R.id.notification_content); - statusAvatar = itemView.findViewById(R.id.notification_status_avatar); - notificationAvatar = itemView.findViewById(R.id.notification_notification_avatar); - contentWarningDescriptionTextView = itemView.findViewById(R.id.notification_content_warning_description); - contentWarningButton = itemView.findViewById(R.id.notification_content_warning_button); - contentCollapseButton = itemView.findViewById(R.id.button_toggle_notification_content); - this.statusDisplayOptions = statusDisplayOptions; - this.absoluteTimeFormatter = absoluteTimeFormatter; - - int darkerFilter = Color.rgb(123, 123, 123); - statusAvatar.setColorFilter(darkerFilter, PorterDuff.Mode.MULTIPLY); - notificationAvatar.setColorFilter(darkerFilter, PorterDuff.Mode.MULTIPLY); - - itemView.setOnClickListener(this); - message.setOnClickListener(this); - statusContent.setOnClickListener(this); - - this.avatarRadius48dp = itemView.getContext().getResources().getDimensionPixelSize(R.dimen.avatar_radius_48dp); - this.avatarRadius36dp = itemView.getContext().getResources().getDimensionPixelSize(R.dimen.avatar_radius_36dp); - this.avatarRadius24dp = itemView.getContext().getResources().getDimensionPixelSize(R.dimen.avatar_radius_24dp); - } - - private void showNotificationContent(boolean show) { - statusNameBar.setVisibility(show ? View.VISIBLE : View.GONE); - contentWarningDescriptionTextView.setVisibility(show ? View.VISIBLE : View.GONE); - contentWarningButton.setVisibility(show ? View.VISIBLE : View.GONE); - statusContent.setVisibility(show ? View.VISIBLE : View.GONE); - statusAvatar.setVisibility(show ? View.VISIBLE : View.GONE); - notificationAvatar.setVisibility(show ? View.VISIBLE : View.GONE); - } - - private void setDisplayName(String name, List emojis) { - CharSequence emojifiedName = CustomEmojiHelper.emojify(name, emojis, displayName, statusDisplayOptions.animateEmojis()); - displayName.setText(emojifiedName); - } - - private void setUsername(String name) { - Context context = username.getContext(); - String format = context.getString(R.string.post_username_format); - String usernameText = String.format(format, name); - username.setText(usernameText); - } - - protected void setCreatedAt(@Nullable Date createdAt) { - if (statusDisplayOptions.useAbsoluteTime()) { - timestampInfo.setText(absoluteTimeFormatter.format(createdAt, true)); - } else { - // This is the visible timestampInfo. - String readout; - /* This one is for screen-readers. Frequently, they would mispronounce timestamps like "17m" - * as 17 meters instead of minutes. */ - CharSequence readoutAloud; - if (createdAt != null) { - long then = createdAt.getTime(); - long now = new Date().getTime(); - readout = TimestampUtils.getRelativeTimeSpanString(timestampInfo.getContext(), then, now); - readoutAloud = android.text.format.DateUtils.getRelativeTimeSpanString(then, now, - android.text.format.DateUtils.SECOND_IN_MILLIS, - android.text.format.DateUtils.FORMAT_ABBREV_RELATIVE); - } else { - // unknown minutes~ - readout = "?m"; - readoutAloud = "? minutes"; - } - timestampInfo.setText(readout); - timestampInfo.setContentDescription(readoutAloud); - } - } - - Drawable getIconWithColor(Context context, @DrawableRes int drawable, @ColorRes int color) { - Drawable icon = ContextCompat.getDrawable(context, drawable); - if (icon != null) { - icon.setColorFilter(context.getColor(color), PorterDuff.Mode.SRC_ATOP); - } - return icon; - } - - void setMessage(NotificationViewData.Concrete notificationViewData, LinkListener listener) { - this.statusViewData = notificationViewData.getStatusViewData(); - - String displayName = StringUtils.unicodeWrap(notificationViewData.getAccount().getName()); - Notification.Type type = notificationViewData.getType(); - - Context context = message.getContext(); - String format; - Drawable icon; - switch (type) { - default: - case FAVOURITE: { - icon = getIconWithColor(context, R.drawable.ic_star_24dp, R.color.tusky_orange); - format = context.getString(R.string.notification_favourite_format); - break; - } - case REBLOG: { - icon = getIconWithColor(context, R.drawable.ic_repeat_24dp, R.color.tusky_blue); - format = context.getString(R.string.notification_reblog_format); - break; - } - case STATUS: { - icon = getIconWithColor(context, R.drawable.ic_home_24dp, R.color.tusky_blue); - format = context.getString(R.string.notification_subscription_format); - break; - } - case UPDATE: { - icon = getIconWithColor(context, R.drawable.ic_edit_24dp, R.color.tusky_blue); - format = context.getString(R.string.notification_update_format); - break; - } - } - message.setCompoundDrawablesWithIntrinsicBounds(icon, null, null, null); - String wholeMessage = String.format(format, displayName); - final SpannableStringBuilder str = new SpannableStringBuilder(wholeMessage); - int displayNameIndex = format.indexOf("%s"); - str.setSpan( - new StyleSpan(Typeface.BOLD), - displayNameIndex, - displayNameIndex + displayName.length(), - Spanned.SPAN_EXCLUSIVE_EXCLUSIVE - ); - CharSequence emojifiedText = CustomEmojiHelper.emojify( - str, notificationViewData.getAccount().getEmojis(), message, statusDisplayOptions.animateEmojis() - ); - message.setText(emojifiedText); - - if (statusViewData != null) { - boolean hasSpoiler = !TextUtils.isEmpty(statusViewData.getStatus().getSpoilerText()); - contentWarningDescriptionTextView.setVisibility(hasSpoiler ? View.VISIBLE : View.GONE); - contentWarningButton.setVisibility(hasSpoiler ? View.VISIBLE : View.GONE); - if (statusViewData.isExpanded()) { - contentWarningButton.setText(R.string.post_content_warning_show_less); - } else { - contentWarningButton.setText(R.string.post_content_warning_show_more); - } - - contentWarningButton.setOnClickListener(view -> { - if (getBindingAdapterPosition() != RecyclerView.NO_POSITION) { - notificationActionListener.onExpandedChange(!statusViewData.isExpanded(), getBindingAdapterPosition()); - } - statusContent.setVisibility(statusViewData.isExpanded() ? View.GONE : View.VISIBLE); - }); - - setupContentAndSpoiler(listener); - } - - } - - void setupButtons(final NotificationActionListener listener, final String accountId, - final String notificationId) { - this.notificationActionListener = listener; - this.accountId = accountId; - this.notificationId = notificationId; - } - - void setAvatar(@Nullable String statusAvatarUrl, boolean isBot) { - statusAvatar.setPaddingRelative(0, 0, 0, 0); - - ImageLoadingHelper.loadAvatar(statusAvatarUrl, - statusAvatar, avatarRadius48dp, statusDisplayOptions.animateAvatars()); - - if (statusDisplayOptions.showBotOverlay() && isBot) { - notificationAvatar.setVisibility(View.VISIBLE); - Glide.with(notificationAvatar) - .load(R.drawable.bot_badge) - .into(notificationAvatar); - - } else { - notificationAvatar.setVisibility(View.GONE); - } - } - - void setAvatars(@Nullable String statusAvatarUrl, @Nullable String notificationAvatarUrl) { - int padding = Utils.dpToPx(statusAvatar.getContext(), 12); - statusAvatar.setPaddingRelative(0, 0, padding, padding); - - ImageLoadingHelper.loadAvatar(statusAvatarUrl, - statusAvatar, avatarRadius36dp, statusDisplayOptions.animateAvatars()); - - notificationAvatar.setVisibility(View.VISIBLE); - ImageLoadingHelper.loadAvatar(notificationAvatarUrl, notificationAvatar, - avatarRadius24dp, statusDisplayOptions.animateAvatars()); - } - - @Override - public void onClick(View v) { - switch (v.getId()) { - case R.id.notification_container: - case R.id.notification_content: - if (notificationActionListener != null) - notificationActionListener.onViewStatusForNotificationId(notificationId); - break; - case R.id.notification_top_text: - if (notificationActionListener != null) - notificationActionListener.onViewAccount(accountId); - break; - } - } - - private void setupContentAndSpoiler(final LinkListener listener) { - - boolean shouldShowContentIfSpoiler = statusViewData.isExpanded(); - boolean hasSpoiler = !TextUtils.isEmpty(statusViewData.getStatus(). getSpoilerText()); - if (!shouldShowContentIfSpoiler && hasSpoiler) { - statusContent.setVisibility(View.GONE); - } else { - statusContent.setVisibility(View.VISIBLE); - } - - Spanned content = statusViewData.getContent(); - List emojis = statusViewData.getActionable().getEmojis(); - - if (statusViewData.isCollapsible() && (statusViewData.isExpanded() || !hasSpoiler)) { - contentCollapseButton.setOnClickListener(view -> { - int position = getBindingAdapterPosition(); - if (position != RecyclerView.NO_POSITION && notificationActionListener != null) { - notificationActionListener.onNotificationContentCollapsedChange(!statusViewData.isCollapsed(), position); - } - }); - - contentCollapseButton.setVisibility(View.VISIBLE); - if (statusViewData.isCollapsed()) { - contentCollapseButton.setText(R.string.post_content_warning_show_more); - statusContent.setFilters(COLLAPSE_INPUT_FILTER); - } else { - contentCollapseButton.setText(R.string.post_content_warning_show_less); - statusContent.setFilters(NO_INPUT_FILTER); - } - } else { - contentCollapseButton.setVisibility(View.GONE); - statusContent.setFilters(NO_INPUT_FILTER); - } - - CharSequence emojifiedText = CustomEmojiHelper.emojify( - content, emojis, statusContent, statusDisplayOptions.animateEmojis() - ); - LinkHelper.setClickableText(statusContent, emojifiedText, statusViewData.getActionable().getMentions(), statusViewData.getActionable().getTags(), listener); - - CharSequence emojifiedContentWarning; - if (statusViewData.getSpoilerText() != null) { - emojifiedContentWarning = CustomEmojiHelper.emojify( - statusViewData.getSpoilerText(), - statusViewData.getActionable().getEmojis(), - contentWarningDescriptionTextView, - statusDisplayOptions.animateEmojis() - ); - } else { - emojifiedContentWarning = ""; - } - contentWarningDescriptionTextView.setText(emojifiedContentWarning); - } - - } -} diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/ReportNotificationViewHolder.kt b/app/src/main/java/com/keylesspalace/tusky/adapter/ReportNotificationViewHolder.kt index db2f79a99..d4712159a 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/ReportNotificationViewHolder.kt +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/ReportNotificationViewHolder.kt @@ -20,28 +20,76 @@ import androidx.core.content.ContextCompat import androidx.recyclerview.widget.RecyclerView import at.connyduck.sparkbutton.helpers.Utils import com.keylesspalace.tusky.R -import com.keylesspalace.tusky.adapter.NotificationsAdapter.NotificationActionListener +import com.keylesspalace.tusky.components.notifications.NotificationActionListener +import com.keylesspalace.tusky.components.notifications.NotificationsPagingAdapter import com.keylesspalace.tusky.databinding.ItemReportNotificationBinding import com.keylesspalace.tusky.entity.Report import com.keylesspalace.tusky.entity.TimelineAccount +import com.keylesspalace.tusky.util.StatusDisplayOptions import com.keylesspalace.tusky.util.emojify import com.keylesspalace.tusky.util.getRelativeTimeSpanString import com.keylesspalace.tusky.util.loadAvatar import com.keylesspalace.tusky.util.unicodeWrap +import com.keylesspalace.tusky.viewdata.NotificationViewData import java.util.Date class ReportNotificationViewHolder( private val binding: ItemReportNotificationBinding, -) : RecyclerView.ViewHolder(binding.root) { + private val notificationActionListener: NotificationActionListener, +) : NotificationsPagingAdapter.ViewHolder, RecyclerView.ViewHolder(binding.root) { - fun setupWithReport(reporter: TimelineAccount, report: Report, animateAvatar: Boolean, animateEmojis: Boolean) { - val reporterName = reporter.name.unicodeWrap().emojify(reporter.emojis, itemView, animateEmojis) - val reporteeName = report.targetAccount.name.unicodeWrap().emojify(report.targetAccount.emojis, itemView, animateEmojis) - val icon = ContextCompat.getDrawable(itemView.context, R.drawable.ic_flag_24dp) + override fun bind( + viewData: NotificationViewData, + payloads: List<*>?, + statusDisplayOptions: StatusDisplayOptions + ) { + // Skip updates with payloads. That indicates a timestamp update, and + // this view does not have timestamps. + if (!payloads.isNullOrEmpty()) return + + setupWithReport( + viewData.account, + viewData.report!!, + statusDisplayOptions.animateAvatars, + statusDisplayOptions.animateEmojis + ) + setupActionListener( + notificationActionListener, + viewData.report.targetAccount.id, + viewData.account.id, + viewData.report.id + ) + } + + private fun setupWithReport( + reporter: TimelineAccount, + report: Report, + animateAvatar: Boolean, + animateEmojis: Boolean + ) { + val reporterName = reporter.name.unicodeWrap().emojify( + reporter.emojis, + binding.root, + animateEmojis + ) + val reporteeName = report.targetAccount.name.unicodeWrap().emojify( + report.targetAccount.emojis, + itemView, + animateEmojis + ) + val icon = ContextCompat.getDrawable(binding.root.context, R.drawable.ic_flag_24dp) binding.notificationTopText.setCompoundDrawablesWithIntrinsicBounds(icon, null, null, null) - binding.notificationTopText.text = itemView.context.getString(R.string.notification_header_report_format, reporterName, reporteeName) - binding.notificationSummary.text = itemView.context.getString(R.string.notification_summary_report_format, getRelativeTimeSpanString(itemView.context, report.createdAt.time, Date().time), report.status_ids?.size ?: 0) + binding.notificationTopText.text = itemView.context.getString( + R.string.notification_header_report_format, + reporterName, + reporteeName + ) + binding.notificationSummary.text = itemView.context.getString( + R.string.notification_summary_report_format, + getRelativeTimeSpanString(itemView.context, report.createdAt.time, Date().time), + report.status_ids?.size ?: 0 + ) binding.notificationCategory.text = getTranslatedCategory(itemView.context, report.category) // Fancy avatar inset @@ -52,17 +100,22 @@ class ReportNotificationViewHolder( report.targetAccount.avatar, binding.notificationReporteeAvatar, itemView.context.resources.getDimensionPixelSize(R.dimen.avatar_radius_36dp), - animateAvatar, + animateAvatar ) loadAvatar( reporter.avatar, binding.notificationReporterAvatar, itemView.context.resources.getDimensionPixelSize(R.dimen.avatar_radius_24dp), - animateAvatar, + animateAvatar ) } - fun setupActionListener(listener: NotificationActionListener, reporteeId: String, reporterId: String, reportId: String) { + private fun setupActionListener( + listener: NotificationActionListener, + reporteeId: String, + reporterId: String, + reportId: String + ) { binding.notificationReporteeAvatar.setOnClickListener { val position = bindingAdapterPosition if (position != RecyclerView.NO_POSITION) { diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/StatusViewHolder.java b/app/src/main/java/com/keylesspalace/tusky/adapter/StatusViewHolder.java index 18a669a16..b1881272d 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/StatusViewHolder.java +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/StatusViewHolder.java @@ -93,7 +93,7 @@ public class StatusViewHolder extends StatusBaseViewHolder { } // don't use this on the same ViewHolder as setRebloggedByDisplayName, will cause recycling issues as paddings are changed - void setPollInfo(final boolean ownPoll) { + protected void setPollInfo(final boolean ownPoll) { statusInfo.setText(ownPoll ? R.string.poll_ended_created : R.string.poll_ended_voted); statusInfo.setCompoundDrawablesRelativeWithIntrinsicBounds(R.drawable.ic_poll_24dp, 0, 0, 0); statusInfo.setCompoundDrawablePadding(Utils.dpToPx(statusInfo.getContext(), 10)); @@ -101,7 +101,7 @@ public class StatusViewHolder extends StatusBaseViewHolder { statusInfo.setVisibility(View.VISIBLE); } - void hideStatusInfo() { + protected void hideStatusInfo() { statusInfo.setVisibility(View.GONE); } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/accountlist/adapter/FollowRequestsAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/accountlist/adapter/FollowRequestsAdapter.kt index 35a59a8e4..ab20d748a 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/accountlist/adapter/FollowRequestsAdapter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/accountlist/adapter/FollowRequestsAdapter.kt @@ -35,8 +35,14 @@ class FollowRequestsAdapter( ) { override fun createAccountViewHolder(parent: ViewGroup): FollowRequestViewHolder { - val binding = ItemFollowRequestBinding.inflate(LayoutInflater.from(parent.context), parent, false) - return FollowRequestViewHolder(binding, false) + val binding = ItemFollowRequestBinding.inflate( + LayoutInflater.from(parent.context), parent, false + ) + return FollowRequestViewHolder( + binding, + accountActionListener, + showHeader = false + ) } override fun onBindAccountViewHolder(viewHolder: FollowRequestViewHolder, position: Int) { diff --git a/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsFragment.kt index fc5cebe6b..9ce9604b2 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsFragment.kt @@ -110,7 +110,9 @@ class ConversationsFragment : confirmReblogs = preferences.getBoolean("confirmReblogs", true), confirmFavourites = preferences.getBoolean("confirmFavourites", false), hideStats = preferences.getBoolean(PrefKeys.WELLBEING_HIDE_STATS_POSTS, false), - animateEmojis = preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false) + animateEmojis = preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false), + showSensitiveMedia = accountManager.activeAccount!!.alwaysShowSensitiveMedia, + openSpoiler = accountManager.activeAccount!!.alwaysOpenSpoiler ) adapter = ConversationAdapter(statusDisplayOptions, this) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/notifications/FollowViewHolder.kt b/app/src/main/java/com/keylesspalace/tusky/components/notifications/FollowViewHolder.kt new file mode 100644 index 000000000..ca19455ba --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/notifications/FollowViewHolder.kt @@ -0,0 +1,100 @@ +/* + * Copyright 2023 Tusky Contributors + * + * This file is a part of Tusky. + * + * This program is free software; you can redistribute it and/or modify it under the terms of the + * GNU General Public License as published by the Free Software Foundation; either version 3 of the + * License, or (at your option) any later version. + * + * Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even + * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General + * Public License for more details. + * + * You should have received a copy of the GNU General Public License along with Tusky; if not, + * see . + */ + +package com.keylesspalace.tusky.components.notifications + +import androidx.recyclerview.widget.RecyclerView +import com.keylesspalace.tusky.R +import com.keylesspalace.tusky.databinding.ItemFollowBinding +import com.keylesspalace.tusky.entity.Notification +import com.keylesspalace.tusky.entity.TimelineAccount +import com.keylesspalace.tusky.util.StatusDisplayOptions +import com.keylesspalace.tusky.util.emojify +import com.keylesspalace.tusky.util.loadAvatar +import com.keylesspalace.tusky.util.unicodeWrap +import com.keylesspalace.tusky.viewdata.NotificationViewData + +class FollowViewHolder( + private val binding: ItemFollowBinding, + private val notificationActionListener: NotificationActionListener, +) : NotificationsPagingAdapter.ViewHolder, RecyclerView.ViewHolder(binding.root) { + private val avatarRadius42dp = itemView.context.resources.getDimensionPixelSize( + R.dimen.avatar_radius_42dp + ) + + override fun bind( + viewData: NotificationViewData, + payloads: List<*>?, + statusDisplayOptions: StatusDisplayOptions + ) { + // Skip updates with payloads. That indicates a timestamp update, and + // this view does not have timestamps. + if (!payloads.isNullOrEmpty()) return + + setMessage( + viewData.account, + viewData.type === Notification.Type.SIGN_UP, + statusDisplayOptions.animateAvatars, + statusDisplayOptions.animateEmojis + ) + setupButtons(notificationActionListener, viewData.account.id) + } + + private fun setMessage( + account: TimelineAccount, + isSignUp: Boolean, + animateAvatars: Boolean, + animateEmojis: Boolean + ) { + val context = binding.notificationText.context + val format = + context.getString( + if (isSignUp) { + R.string.notification_sign_up_format + } else { + R.string.notification_follow_format + } + ) + val wrappedDisplayName = account.name.unicodeWrap() + val wholeMessage = String.format(format, wrappedDisplayName) + val emojifiedMessage = + wholeMessage.emojify( + account.emojis, + binding.notificationText, + animateEmojis + ) + binding.notificationText.text = emojifiedMessage + val username = context.getString(R.string.post_username_format, account.username) + binding.notificationUsername.text = username + val emojifiedDisplayName = wrappedDisplayName.emojify( + account.emojis, + binding.notificationUsername, + animateEmojis + ) + binding.notificationDisplayName.text = emojifiedDisplayName + loadAvatar( + account.avatar, + binding.notificationAvatar, + avatarRadius42dp, + animateAvatars + ) + } + + private fun setupButtons(listener: NotificationActionListener, accountId: String) { + binding.root.setOnClickListener { listener.onViewAccount(accountId) } + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsFragment.kt new file mode 100644 index 000000000..b79156ebf --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsFragment.kt @@ -0,0 +1,681 @@ +/* + * Copyright 2023 Tusky Contributors + * + * This file is a part of Tusky. + * + * This program is free software; you can redistribute it and/or modify it under the terms of the + * GNU General Public License as published by the Free Software Foundation; either version 3 of the + * License, or (at your option) any later version. + * + * Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even + * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General + * Public License for more details. + * + * You should have received a copy of the GNU General Public License along with Tusky; if not, + * see . + */ + +package com.keylesspalace.tusky.components.notifications + +import android.app.Dialog +import android.content.DialogInterface +import android.os.Bundle +import android.util.Log +import android.view.LayoutInflater +import android.view.Menu +import android.view.MenuInflater +import android.view.MenuItem +import android.view.View +import android.view.ViewGroup +import androidx.appcompat.app.AlertDialog +import androidx.coordinatorlayout.widget.CoordinatorLayout +import androidx.core.view.MenuProvider +import androidx.core.view.isVisible +import androidx.fragment.app.DialogFragment +import androidx.fragment.app.viewModels +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import androidx.paging.LoadState +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.DividerItemDecoration +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import androidx.recyclerview.widget.SimpleItemAnimator +import androidx.swiperefreshlayout.widget.SwipeRefreshLayout.OnRefreshListener +import at.connyduck.sparkbutton.helpers.Utils +import com.google.android.material.appbar.AppBarLayout.ScrollingViewBehavior +import com.google.android.material.color.MaterialColors +import com.google.android.material.snackbar.Snackbar +import com.keylesspalace.tusky.R +import com.keylesspalace.tusky.adapter.StatusBaseViewHolder +import com.keylesspalace.tusky.databinding.FragmentTimelineNotificationsBinding +import com.keylesspalace.tusky.di.Injectable +import com.keylesspalace.tusky.di.ViewModelFactory +import com.keylesspalace.tusky.entity.Notification +import com.keylesspalace.tusky.entity.Status +import com.keylesspalace.tusky.fragment.SFragment +import com.keylesspalace.tusky.interfaces.AccountActionListener +import com.keylesspalace.tusky.interfaces.ActionButtonActivity +import com.keylesspalace.tusky.interfaces.ReselectableFragment +import com.keylesspalace.tusky.interfaces.StatusActionListener +import com.keylesspalace.tusky.util.ListStatusAccessibilityDelegate +import com.keylesspalace.tusky.util.openLink +import com.keylesspalace.tusky.util.viewBinding +import com.keylesspalace.tusky.viewdata.AttachmentViewData.Companion.list +import com.keylesspalace.tusky.viewdata.NotificationViewData +import com.mikepenz.iconics.IconicsDrawable +import com.mikepenz.iconics.typeface.library.googlematerial.GoogleMaterial +import com.mikepenz.iconics.utils.colorInt +import com.mikepenz.iconics.utils.sizeDp +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.distinctUntilChangedBy +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch +import java.io.IOException +import javax.inject.Inject + +class NotificationsFragment : + SFragment(), + StatusActionListener, + NotificationActionListener, + AccountActionListener, + OnRefreshListener, + MenuProvider, + Injectable, + ReselectableFragment { + + @Inject + lateinit var viewModelFactory: ViewModelFactory + + private val viewModel: NotificationsViewModel by viewModels { viewModelFactory } + + private val binding by viewBinding(FragmentTimelineNotificationsBinding::bind) + + private lateinit var adapter: NotificationsPagingAdapter + + private lateinit var layoutManager: LinearLayoutManager + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + adapter = NotificationsPagingAdapter( + notificationDiffCallback, + accountId = accountManager.activeAccount!!.accountId, + statusActionListener = this, + notificationActionListener = this, + accountActionListener = this, + statusDisplayOptions = viewModel.statusDisplayOptions.value + ) + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + return inflater.inflate(R.layout.fragment_timeline_notifications, container, false) + } + + private fun updateFilterVisibility(showFilter: Boolean) { + val params = binding.swipeRefreshLayout.layoutParams as CoordinatorLayout.LayoutParams + if (showFilter) { + binding.appBarOptions.setExpanded(true, false) + binding.appBarOptions.visibility = View.VISIBLE + // Set content behaviour to hide filter on scroll + params.behavior = ScrollingViewBehavior() + } else { + binding.appBarOptions.setExpanded(false, false) + binding.appBarOptions.visibility = View.GONE + // Clear behaviour to hide app bar + params.behavior = null + } + } + + private fun confirmClearNotifications() { + AlertDialog.Builder(requireContext()) + .setMessage(R.string.notification_clear_text) + .setPositiveButton(android.R.string.ok) { _: DialogInterface?, _: Int -> clearNotifications() } + .setNegativeButton(android.R.string.cancel, null) + .show() + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + requireActivity().addMenuProvider(this, viewLifecycleOwner, Lifecycle.State.RESUMED) + + // Setup the SwipeRefreshLayout. + binding.swipeRefreshLayout.setOnRefreshListener(this) + binding.swipeRefreshLayout.setColorSchemeResources(R.color.tusky_blue) + + // Setup the RecyclerView. + binding.recyclerView.setHasFixedSize(true) + layoutManager = LinearLayoutManager(context) + binding.recyclerView.layoutManager = layoutManager + binding.recyclerView.setAccessibilityDelegateCompat( + ListStatusAccessibilityDelegate( + binding.recyclerView, + this + ) { pos: Int -> + val notification = adapter.snapshot()[pos] + // We support replies only for now + if (notification is NotificationViewData) { + notification.statusViewData + } else { + null + } + } + ) + binding.recyclerView.addItemDecoration( + DividerItemDecoration( + context, + DividerItemDecoration.VERTICAL + ) + ) + + binding.recyclerView.addOnScrollListener(object : RecyclerView.OnScrollListener() { + val actionButton = (activity as ActionButtonActivity).actionButton + + override fun onScrolled(view: RecyclerView, dx: Int, dy: Int) { + actionButton?.let { fab -> + if (!viewModel.uiState.value.showFabWhileScrolling) { + if (dy > 0 && fab.isShown) { + fab.hide() // Hide when scrolling down + } else if (dy < 0 && !fab.isShown) { + fab.show() // Show when scrolling up + } + } else if (!fab.isShown) { + fab.show() + } + } + } + }) + + binding.recyclerView.adapter = adapter.withLoadStateHeaderAndFooter( + header = NotificationsLoadStateAdapter { adapter.retry() }, + footer = NotificationsLoadStateAdapter { adapter.retry() } + ) + + binding.buttonClear.setOnClickListener { confirmClearNotifications() } + binding.buttonFilter.setOnClickListener { showFilterDialog() } + (binding.recyclerView.itemAnimator as SimpleItemAnimator?)!!.supportsChangeAnimations = + false + + // Signal the user that a refresh has loaded new items above their current position + // by scrolling up slightly to disclose the new content + adapter.registerAdapterDataObserver(object : RecyclerView.AdapterDataObserver() { + override fun onItemRangeInserted(positionStart: Int, itemCount: Int) { + if (positionStart == 0 && adapter.itemCount != itemCount) { + binding.recyclerView.post { + binding.recyclerView.scrollBy(0, Utils.dpToPx(requireContext(), -30)) + } + } + } + }) + + /** + * Collect this flow to notify the adapter that the timestamps of the visible items have + * changed + */ + val updateTimestampFlow = flow { + while (true) { delay(60000); emit(Unit) } + }.onEach { + layoutManager.findFirstVisibleItemPosition().let { first -> + first == RecyclerView.NO_POSITION && return@let + val count = layoutManager.findLastVisibleItemPosition() - first + adapter.notifyItemRangeChanged( + first, + count, + listOf(StatusBaseViewHolder.Key.KEY_CREATED) + ) + } + } + + viewLifecycleOwner.lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.STARTED) { + launch { + viewModel.pagingData.collectLatest { pagingData -> + Log.d(TAG, "Submitting data to adapter") + adapter.submitData(pagingData) + } + } + + // Show errors from the view model as snack bars. + // + // Errors are shown: + // - Indefinitely, so the user has a chance to read and understand + // the message + // - With a max of 5 text lines, to allow space for longer errors. + // E.g., on a typical device, an error message like "Bookmarking + // post failed: Unable to resolve host 'mastodon.social': No + // address associated with hostname" is 3 lines. + // - With a "Retry" option if the error included a UiAction to retry. + launch { + viewModel.uiError.collect { error -> + Log.d(TAG, error.toString()) + val message = getString( + error.message, + error.exception.localizedMessage + ?: getString(R.string.ui_error_unknown) + ) + val snackbar = Snackbar.make( + // Without this the FAB will not move out of the way + (activity as ActionButtonActivity).actionButton ?: binding.root, + message, + Snackbar.LENGTH_INDEFINITE + ).setTextMaxLines(5) + error.action?.let { action -> + snackbar.setAction(R.string.action_retry) { + viewModel.accept(action) + } + } + snackbar.show() + + // The status view has pre-emptively updated its state to show + // that the action succeeded. Since it hasn't, re-bind the view + // to show the correct data. + error.action?.let { action -> + action is StatusAction || return@let + + val position = adapter.snapshot().indexOfFirst { + it?.statusViewData?.status?.id == (action as StatusAction).statusViewData.id + } + if (position != RecyclerView.NO_POSITION) { + adapter.notifyItemChanged(position) + } + } + } + } + + // Show successful notification action as brief snackbars, so the + // user is clear the action has happened. + launch { + viewModel.uiSuccess + .filterIsInstance() + .collect { + Snackbar.make( + (activity as ActionButtonActivity).actionButton ?: binding.root, + getString(it.msg), + Snackbar.LENGTH_SHORT + ).show() + + when (it) { + // The follow request is no longer valid, refresh the adapter to + // remove it. + is NotificationActionSuccess.AcceptFollowRequest, + is NotificationActionSuccess.RejectFollowRequest -> adapter.refresh() + } + } + } + + // Update adapter data when status actions are successful, and re-bind to update + // the UI. + launch { + viewModel.uiSuccess + .filterIsInstance() + .collect { + val indexedViewData = adapter.snapshot() + .withIndex() + .firstOrNull { notificationViewData -> + notificationViewData.value?.statusViewData?.status?.id == + it.action.statusViewData.id + } ?: return@collect + + val statusViewData = + indexedViewData.value?.statusViewData ?: return@collect + + val status = when (it) { + is StatusActionSuccess.Bookmark -> + statusViewData.status.copy(bookmarked = it.action.state) + is StatusActionSuccess.Favourite -> + statusViewData.status.copy(favourited = it.action.state) + is StatusActionSuccess.Reblog -> + statusViewData.status.copy(reblogged = it.action.state) + is StatusActionSuccess.VoteInPoll -> + statusViewData.status.copy( + poll = it.action.poll.votedCopy(it.action.choices) + ) + } + indexedViewData.value?.statusViewData = statusViewData.copy( + status = status + ) + + adapter.notifyItemChanged(indexedViewData.index) + } + } + + // Refresh adapter on mutes and blocks + launch { + viewModel.uiSuccess.collectLatest { + when (it) { + is UiSuccess.Block, is UiSuccess.Mute, is UiSuccess.MuteConversation -> + adapter.refresh() + else -> { /* nothing to do */ + } + } + } + } + + // Update filter option visibility from uiState + launch { + viewModel.uiState.collectLatest { updateFilterVisibility(it.showFilterOptions) } + } + + // Update status display from statusDisplayOptions. If the new options request + // relative time display collect the flow to periodically re-bind the UI. + launch { + viewModel.statusDisplayOptions + .collectLatest { + adapter.statusDisplayOptions = it + layoutManager.findFirstVisibleItemPosition().let { first -> + first == RecyclerView.NO_POSITION && return@let + val count = layoutManager.findLastVisibleItemPosition() - first + adapter.notifyItemRangeChanged( + first, + count, + null + ) + } + + if (!it.useAbsoluteTime) { + updateTimestampFlow.collect() + } + } + } + + // Update the UI from the loadState + adapter.loadStateFlow + .distinctUntilChangedBy { it.refresh } + .collect { loadState -> + binding.recyclerView.isVisible = true + binding.progressBar.isVisible = loadState.refresh is LoadState.Loading && + !binding.swipeRefreshLayout.isRefreshing + binding.swipeRefreshLayout.isRefreshing = + loadState.refresh is LoadState.Loading && !binding.progressBar.isVisible + + binding.statusView.isVisible = false + if (loadState.refresh is LoadState.NotLoading) { + if (adapter.itemCount == 0) { + binding.statusView.setup( + R.drawable.elephant_friend_empty, + R.string.message_empty + ) + binding.recyclerView.isVisible = false + binding.statusView.isVisible = true + } else { + binding.statusView.isVisible = false + } + } + + if (loadState.refresh is LoadState.Error) { + when ((loadState.refresh as LoadState.Error).error) { + is IOException -> { + binding.statusView.setup( + R.drawable.elephant_offline, + R.string.error_network + ) { adapter.retry() } + } + else -> { + binding.statusView.setup( + R.drawable.elephant_error, + R.string.error_generic + ) { adapter.retry() } + } + } + binding.recyclerView.isVisible = false + binding.statusView.isVisible = true + } + } + } + } + } + + override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) { + menuInflater.inflate(R.menu.fragment_notifications, menu) + menu.findItem(R.id.action_refresh)?.apply { + icon = IconicsDrawable(requireContext(), GoogleMaterial.Icon.gmd_refresh).apply { + sizeDp = 20 + colorInt = MaterialColors.getColor(binding.root, android.R.attr.textColorPrimary) + } + } + } + + override fun onMenuItemSelected(menuItem: MenuItem): Boolean { + return when (menuItem.itemId) { + R.id.action_refresh -> { + binding.swipeRefreshLayout.isRefreshing = true + onRefresh() + true + } + else -> false + } + } + + override fun onRefresh() { + binding.progressBar.isVisible = false + adapter.refresh() + } + + override fun onPause() { + super.onPause() + + // Save the ID of the first notification visible in the list + val position = layoutManager.findFirstVisibleItemPosition() + if (position >= 0) { + adapter.snapshot()[position]?.id?.let { id -> + viewModel.accept(InfallibleUiAction.SaveVisibleId(visibleId = id)) + } + } + } + + override fun onResume() { + super.onResume() + NotificationHelper.clearNotificationsForActiveAccount(requireContext(), accountManager) + } + + override fun onReply(position: Int) { + val status = adapter.peek(position)?.statusViewData?.status ?: return + super.reply(status) + } + + override fun onReblog(reblog: Boolean, position: Int) { + val statusViewData = adapter.peek(position)?.statusViewData ?: return + viewModel.accept(StatusAction.Reblog(reblog, statusViewData)) + } + + override fun onFavourite(favourite: Boolean, position: Int) { + val statusViewData = adapter.peek(position)?.statusViewData ?: return + viewModel.accept(StatusAction.Favourite(favourite, statusViewData)) + } + + override fun onBookmark(bookmark: Boolean, position: Int) { + val statusViewData = adapter.peek(position)?.statusViewData ?: return + viewModel.accept(StatusAction.Bookmark(bookmark, statusViewData)) + } + + override fun onVoteInPoll(position: Int, choices: List) { + val statusViewData = adapter.peek(position)?.statusViewData ?: return + val poll = statusViewData.status.poll ?: return + viewModel.accept(StatusAction.VoteInPoll(poll, choices, statusViewData)) + } + + override fun onMore(view: View, position: Int) { + val status = adapter.peek(position)?.statusViewData?.status ?: return + super.more(status, view, position) + } + + override fun onViewMedia(position: Int, attachmentIndex: Int, view: View?) { + val status = adapter.peek(position)?.statusViewData?.status ?: return + super.viewMedia(attachmentIndex, list(status), view) + } + + override fun onViewThread(position: Int) { + val status = adapter.peek(position)?.statusViewData?.status ?: return + super.viewThread(status.actionableId, status.actionableStatus.url) + } + + override fun onOpenReblog(position: Int) { + val account = adapter.peek(position)?.account!! + onViewAccount(account.id) + } + + override fun onExpandedChange(expanded: Boolean, position: Int) { + val notificationViewData = adapter.snapshot()[position] ?: return + notificationViewData.statusViewData = notificationViewData.statusViewData?.copy( + isExpanded = expanded + ) + adapter.notifyItemChanged(position) + } + + override fun onContentHiddenChange(isShowing: Boolean, position: Int) { + val notificationViewData = adapter.snapshot()[position] ?: return + notificationViewData.statusViewData = notificationViewData.statusViewData?.copy( + isShowingContent = isShowing + ) + adapter.notifyItemChanged(position) + } + + override fun onLoadMore(position: Int) { + // Empty -- this fragment doesn't show placeholders + } + + override fun onContentCollapsedChange(isCollapsed: Boolean, position: Int) { + val notificationViewData = adapter.snapshot()[position] ?: return + notificationViewData.statusViewData = notificationViewData.statusViewData?.copy( + isCollapsed = isCollapsed + ) + adapter.notifyItemChanged(position) + } + + override fun onNotificationContentCollapsedChange(isCollapsed: Boolean, position: Int) { + onContentCollapsedChange(isCollapsed, position) + } + + private fun clearNotifications() { + binding.swipeRefreshLayout.isRefreshing = false + binding.progressBar.isVisible = false + viewModel.accept(FallibleUiAction.ClearNotifications) + } + + private fun showFilterDialog() { + FilterDialogFragment(viewModel.uiState.value.activeFilter) { filter -> + if (viewModel.uiState.value.activeFilter != filter) { + viewModel.accept(InfallibleUiAction.ApplyFilter(filter)) + } + } + .show(parentFragmentManager, "dialogFilter") + } + + override fun onViewTag(tag: String) { + super.viewTag(tag) + } + + override fun onViewAccount(id: String) { + super.viewAccount(id) + } + + override fun onMute(mute: Boolean, id: String, position: Int, notifications: Boolean) { + adapter.refresh() + } + + override fun onBlock(block: Boolean, id: String, position: Int) { + adapter.refresh() + } + + override fun onRespondToFollowRequest(accept: Boolean, accountId: String, position: Int) { + if (accept) { + viewModel.accept(NotificationAction.AcceptFollowRequest(accountId)) + } else { + viewModel.accept(NotificationAction.RejectFollowRequest(accountId)) + } + } + + override fun onViewThreadForStatus(status: Status) { + super.viewThread(status.actionableId, status.actionableStatus.url) + } + + override fun onViewReport(reportId: String) { + requireContext().openLink( + "https://${accountManager.activeAccount!!.domain}/admin/reports/$reportId" + ) + } + + public override fun removeItem(position: Int) { + // Empty -- this fragment doesn't remove items + } + + override fun onReselect() { + if (isAdded) { + binding.appBarOptions.setExpanded(true, false) + layoutManager.scrollToPosition(0) + } + } + + companion object { + private const val TAG = "NotificationF" + fun newInstance() = NotificationsFragment() + + private val notificationDiffCallback: DiffUtil.ItemCallback = + object : DiffUtil.ItemCallback() { + override fun areItemsTheSame( + oldItem: NotificationViewData, + newItem: NotificationViewData + ): Boolean { + return oldItem.id == newItem.id + } + + override fun areContentsTheSame( + oldItem: NotificationViewData, + newItem: NotificationViewData + ): Boolean { + return false + } + + override fun getChangePayload( + oldItem: NotificationViewData, + newItem: NotificationViewData + ): Any? { + return if (oldItem == newItem) { + // If items are equal - update timestamp only + listOf(StatusBaseViewHolder.Key.KEY_CREATED) + } else { + // If items are different - update a whole view holder + null + } + } + } + } +} + +class FilterDialogFragment( + private val activeFilter: Set, + private val listener: ((filter: Set) -> Unit) +) : DialogFragment() { + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + val context = requireContext() + + val items = Notification.Type.visibleTypes.map { getString(it.uiString) }.toTypedArray() + val checkedItems = Notification.Type.visibleTypes.map { + !activeFilter.contains(it) + }.toBooleanArray() + + val builder = AlertDialog.Builder(context) + .setTitle(R.string.notifications_apply_filter) + .setMultiChoiceItems(items, checkedItems) { _, which, isChecked -> + checkedItems[which] = isChecked + } + .setPositiveButton(android.R.string.ok) { _, _ -> + val excludes: MutableSet = HashSet() + for (i in Notification.Type.visibleTypes.indices) { + if (!checkedItems[i]) excludes.add(Notification.Type.visibleTypes[i]) + } + listener(excludes) + } + .setNegativeButton(android.R.string.cancel) { _, _ -> } + return builder.create() + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsLoadStateAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsLoadStateAdapter.kt new file mode 100644 index 000000000..0a281ccd9 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsLoadStateAdapter.kt @@ -0,0 +1,38 @@ +/* + * Copyright 2023 Tusky Contributors + * + * This file is a part of Tusky. + * + * This program is free software; you can redistribute it and/or modify it under the terms of the + * GNU General Public License as published by the Free Software Foundation; either version 3 of the + * License, or (at your option) any later version. + * + * Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even + * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General + * Public License for more details. + * + * You should have received a copy of the GNU General Public License along with Tusky; if not, + * see . + */ + +package com.keylesspalace.tusky.components.notifications + +import android.view.ViewGroup +import androidx.paging.LoadState +import androidx.paging.LoadStateAdapter + +/** Show load state and retry options when loading notifications */ +class NotificationsLoadStateAdapter( + private val retry: () -> Unit +) : LoadStateAdapter() { + override fun onCreateViewHolder( + parent: ViewGroup, + loadState: LoadState + ): NotificationsLoadStateViewHolder { + return NotificationsLoadStateViewHolder.create(parent, retry) + } + + override fun onBindViewHolder(holder: NotificationsLoadStateViewHolder, loadState: LoadState) { + holder.bind(loadState) + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsLoadStateViewHolder.kt b/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsLoadStateViewHolder.kt new file mode 100644 index 000000000..f3c006d32 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsLoadStateViewHolder.kt @@ -0,0 +1,73 @@ +/* + * Copyright 2023 Tusky Contributors + * + * This file is a part of Tusky. + * + * This program is free software; you can redistribute it and/or modify it under the terms of the + * GNU General Public License as published by the Free Software Foundation; either version 3 of the + * License, or (at your option) any later version. + * + * Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even + * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General + * Public License for more details. + * + * You should have received a copy of the GNU General Public License along with Tusky; if not, + * see . + */ + +package com.keylesspalace.tusky.components.notifications + +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.core.view.isVisible +import androidx.paging.LoadState +import androidx.recyclerview.widget.RecyclerView +import com.keylesspalace.tusky.R +import com.keylesspalace.tusky.databinding.ItemNotificationsLoadStateFooterViewBinding +import java.net.SocketTimeoutException + +/** + * Display the header/footer loading state to the user. + * + * Either: + * + * 1. A page is being loaded, display a progress view, or + * 2. An error occurred, display an error message with a "retry" button + * + * @param retry function to invoke if the user clicks the "retry" button + */ +class NotificationsLoadStateViewHolder( + private val binding: ItemNotificationsLoadStateFooterViewBinding, + retry: () -> Unit +) : RecyclerView.ViewHolder(binding.root) { + init { + binding.retryButton.setOnClickListener { retry.invoke() } + } + + fun bind(loadState: LoadState) { + if (loadState is LoadState.Error) { + val ctx = binding.root.context + binding.errorMsg.text = when (loadState.error) { + is SocketTimeoutException -> ctx.getString(R.string.socket_timeout_exception) + // Other exceptions to consider: + // - UnknownHostException, default text is: + // Unable to resolve "%s": No address associated with hostname + else -> loadState.error.localizedMessage + } + } + binding.progressBar.isVisible = loadState is LoadState.Loading + binding.retryButton.isVisible = loadState is LoadState.Error + binding.errorMsg.isVisible = loadState is LoadState.Error + } + + companion object { + fun create(parent: ViewGroup, retry: () -> Unit): NotificationsLoadStateViewHolder { + val binding = ItemNotificationsLoadStateFooterViewBinding.inflate( + LayoutInflater.from(parent.context), + parent, + false + ) + return NotificationsLoadStateViewHolder(binding, retry) + } + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsPagingAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsPagingAdapter.kt new file mode 100644 index 000000000..067778e20 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsPagingAdapter.kt @@ -0,0 +1,209 @@ +/* + * Copyright 2023 Tusky Contributors + * + * This file is a part of Tusky. + * + * This program is free software; you can redistribute it and/or modify it under the terms of the + * GNU General Public License as published by the Free Software Foundation; either version 3 of the + * License, or (at your option) any later version. + * + * Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even + * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General + * Public License for more details. + * + * You should have received a copy of the GNU General Public License along with Tusky; if not, + * see . + */ + +package com.keylesspalace.tusky.components.notifications + +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.paging.PagingDataAdapter +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.RecyclerView +import com.keylesspalace.tusky.adapter.FollowRequestViewHolder +import com.keylesspalace.tusky.adapter.ReportNotificationViewHolder +import com.keylesspalace.tusky.databinding.ItemFollowBinding +import com.keylesspalace.tusky.databinding.ItemFollowRequestBinding +import com.keylesspalace.tusky.databinding.ItemReportNotificationBinding +import com.keylesspalace.tusky.databinding.ItemStatusBinding +import com.keylesspalace.tusky.databinding.ItemStatusNotificationBinding +import com.keylesspalace.tusky.databinding.SimpleListItem1Binding +import com.keylesspalace.tusky.entity.Notification +import com.keylesspalace.tusky.entity.Status +import com.keylesspalace.tusky.interfaces.AccountActionListener +import com.keylesspalace.tusky.interfaces.StatusActionListener +import com.keylesspalace.tusky.util.AbsoluteTimeFormatter +import com.keylesspalace.tusky.util.StatusDisplayOptions +import com.keylesspalace.tusky.viewdata.NotificationViewData + +/** How to present the notification in the UI */ +enum class NotificationViewKind { + /** View as the original status */ + STATUS, + + /** View as the original status, with the interaction type above */ + NOTIFICATION, + FOLLOW, + FOLLOW_REQUEST, + REPORT, + UNKNOWN; + + companion object { + fun from(kind: Notification.Type?): NotificationViewKind { + return when (kind) { + Notification.Type.MENTION, + Notification.Type.POLL, + Notification.Type.UNKNOWN -> STATUS + Notification.Type.FAVOURITE, + Notification.Type.REBLOG, + Notification.Type.STATUS, + Notification.Type.UPDATE -> NOTIFICATION + Notification.Type.FOLLOW, + Notification.Type.SIGN_UP -> FOLLOW + Notification.Type.FOLLOW_REQUEST -> FOLLOW_REQUEST + Notification.Type.REPORT -> REPORT + null -> UNKNOWN + } + } + } +} + +interface NotificationActionListener { + fun onViewAccount(id: String) + fun onViewThreadForStatus(status: Status) + fun onViewReport(reportId: String) + + /** + * Called when the status has a content warning and the visibility of the content behind + * the warning is being changed. + * + * @param expanded the desired state of the content behind the content warning + * @param position the adapter position of the view + * + */ + fun onExpandedChange(expanded: Boolean, position: Int) + + /** + * Called when the status [android.widget.ToggleButton] responsible for collapsing long + * status content is interacted with. + * + * @param isCollapsed Whether the status content is shown in a collapsed state or fully. + * @param position The position of the status in the list. + */ + fun onNotificationContentCollapsedChange(isCollapsed: Boolean, position: Int) +} + +class NotificationsPagingAdapter( + diffCallback: DiffUtil.ItemCallback, + /** ID of the the account that notifications are being displayed for */ + private val accountId: String, + private val statusActionListener: StatusActionListener, + private val notificationActionListener: NotificationActionListener, + private val accountActionListener: AccountActionListener, + var statusDisplayOptions: StatusDisplayOptions +) : PagingDataAdapter(diffCallback) { + + private val absoluteTimeFormatter = AbsoluteTimeFormatter() + + /** View holders in this adapter must implement this interface */ + interface ViewHolder { + /** Bind the data from the notification and payloads to the view */ + fun bind( + viewData: NotificationViewData, + payloads: List<*>?, + statusDisplayOptions: StatusDisplayOptions + ) + } + + init { + stateRestorationPolicy = StateRestorationPolicy.PREVENT_WHEN_EMPTY + } + + override fun getItemViewType(position: Int): Int { + return NotificationViewKind.from(getItem(position)?.type).ordinal + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { + val inflater = LayoutInflater.from(parent.context) + + return when (NotificationViewKind.values()[viewType]) { + NotificationViewKind.STATUS -> { + StatusViewHolder( + ItemStatusBinding.inflate(inflater, parent, false), + statusActionListener, + accountId + ) + } + NotificationViewKind.NOTIFICATION -> { + StatusNotificationViewHolder( + ItemStatusNotificationBinding.inflate(inflater, parent, false), + statusActionListener, + notificationActionListener, + absoluteTimeFormatter + ) + } + NotificationViewKind.FOLLOW -> { + FollowViewHolder( + ItemFollowBinding.inflate(inflater, parent, false), + notificationActionListener + ) + } + NotificationViewKind.FOLLOW_REQUEST -> { + FollowRequestViewHolder( + ItemFollowRequestBinding.inflate(inflater, parent, false), + accountActionListener, + showHeader = true + ) + } + NotificationViewKind.REPORT -> { + ReportNotificationViewHolder( + ItemReportNotificationBinding.inflate(inflater, parent, false), + notificationActionListener + ) + } + else -> { + FallbackNotificationViewHolder( + SimpleListItem1Binding.inflate(inflater, parent, false) + ) + } + } + } + + override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { + bindViewHolder(holder, position, null) + } + + override fun onBindViewHolder( + holder: RecyclerView.ViewHolder, + position: Int, + payloads: MutableList + ) { + bindViewHolder(holder, position, payloads) + } + + private fun bindViewHolder( + holder: RecyclerView.ViewHolder, + position: Int, + payloads: List<*>? + ) { + getItem(position)?.let { (holder as ViewHolder).bind(it, payloads, statusDisplayOptions) } + } + + /** + * Notification view holder to use if no other type is appropriate. Should never normally + * be used, but is useful when migrating code. + */ + private class FallbackNotificationViewHolder( + val binding: SimpleListItem1Binding + ) : ViewHolder, RecyclerView.ViewHolder(binding.root) { + override fun bind( + viewData: NotificationViewData, + payloads: List<*>?, + statusDisplayOptions: StatusDisplayOptions + ) { + binding.text1.text = viewData.statusViewData?.content + } + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsPagingSource.kt b/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsPagingSource.kt new file mode 100644 index 000000000..44db2308b --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsPagingSource.kt @@ -0,0 +1,184 @@ +/* + * Copyright 2023 Tusky Contributors + * + * This file is a part of Tusky. + * + * This program is free software; you can redistribute it and/or modify it under the terms of the + * GNU General Public License as published by the Free Software Foundation; either version 3 of the + * License, or (at your option) any later version. + * + * Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even + * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General + * Public License for more details. + * + * You should have received a copy of the GNU General Public License along with Tusky; if not, + * see . + */ + +package com.keylesspalace.tusky.components.notifications + +import android.util.Log +import androidx.paging.PagingSource +import androidx.paging.PagingState +import com.keylesspalace.tusky.entity.Notification +import com.keylesspalace.tusky.network.MastodonApi +import com.keylesspalace.tusky.util.HttpHeaderLink +import kotlinx.coroutines.async +import kotlinx.coroutines.coroutineScope +import okhttp3.Headers +import retrofit2.Response +import javax.inject.Inject + +/** Models next/prev links from the "Links" header in an API response */ +data class Links(val next: String?, val prev: String?) + +/** [PagingSource] for Mastodon Notifications, identified by the Notification ID */ +class NotificationsPagingSource @Inject constructor( + private val mastodonApi: MastodonApi, + private val notificationFilter: Set +) : PagingSource() { + override suspend fun load(params: LoadParams): LoadResult { + Log.d(TAG, "load() with ${params.javaClass.simpleName} for key: ${params.key}") + + try { + val response = when (params) { + is LoadParams.Refresh -> { + getInitialPage(params) + } + is LoadParams.Append -> mastodonApi.notifications( + maxId = params.key, + limit = params.loadSize, + excludes = notificationFilter + ) + is LoadParams.Prepend -> mastodonApi.notifications( + minId = params.key, + limit = params.loadSize, + excludes = notificationFilter + ) + } + + if (!response.isSuccessful) { + return LoadResult.Error(Throwable(response.errorBody().toString())) + } + + val links = getPageLinks(response.headers()["link"]) + return LoadResult.Page( + data = response.body()!!, + nextKey = links.next, + prevKey = links.prev + ) + } catch (e: Exception) { + return LoadResult.Error(e) + } + } + + /** + * Fetch the initial page of notifications, using params.key as the ID of the initial + * notification to fetch. + * + * - If there is no key, a page of the most recent notifications is returned + * - If the notification exists, and is not filtered, a page of notifications is returned + * - If the notification does not exist, or is filtered, the page of notifications immediately + * before is returned + * - If there is no page of notifications immediately before then the page immediately after + * is returned + */ + private suspend fun getInitialPage(params: LoadParams): Response> = coroutineScope { + // If the key is null this is straightforward, just return the most recent notifications. + val key = params.key + ?: return@coroutineScope mastodonApi.notifications( + limit = params.loadSize, + excludes = notificationFilter + ) + + // It's important to return *something* from this state. If an empty page is returned + // (even with next/prev links) Pager3 assumes there is no more data to load and stops. + // + // In addition, the Mastodon API does not let you fetch a page that contains a given key. + // You can fetch the page immediately before the key, or the page immediately after, but + // you can not fetch the page itself. + + // First, try and get the notification itself, and the notifications immediately before + // it. This is so that a full page of results can be returned. Returning just the + // single notification means the displayed list can jump around a bit as more data is + // loaded. + // + // Make both requests, and wait for the first to complete. + val deferredNotification = async { mastodonApi.notification(id = key) } + val deferredNotificationPage = async { + mastodonApi.notifications(maxId = key, limit = params.loadSize, excludes = notificationFilter) + } + + val notification = deferredNotification.await() + if (notification.isSuccessful) { + // If this was successful we must still check that the user is not filtering this type + // of notification, as fetching a single notification ignores filters. Returning this + // notification if the user is filtering the type is wrong. + notification.body()?.let { body -> + if (!notificationFilter.contains(body.type)) { + // Notification is *not* filtered. We can return this, but need the next page of + // notifications as well + + // Collect all notifications in to this list + val notifications = mutableListOf(body) + val notificationPage = deferredNotificationPage.await() + if (notificationPage.isSuccessful) { + notificationPage.body()?.let { + notifications.addAll(it) + } + } + + // "notifications" now contains at least one notification we can return, and + // hopefully a full page. + + // Build correct max_id and min_id links for the response. The "min_id" to use + // when fetching the next page is the same as "key". The "max_id" is the ID of + // the oldest notification in the list. + val maxId = notifications.last().id + val headers = Headers.Builder() + .add("link: ; rel=\"next\", ; rel=\"prev\"") + .build() + + return@coroutineScope Response.success(notifications, headers) + } + } + } + + // The user's last read notification was missing or is filtered. Use the page of + // notifications chronologically older than their desired notification. + deferredNotificationPage.await().apply { + if (this.isSuccessful) return@coroutineScope this + } + + // There were no notifications older than the user's desired notification. Return the page + // of notifications immediately newer than their desired notification. + return@coroutineScope mastodonApi.notifications( + minId = key, + limit = params.loadSize, + excludes = notificationFilter + ) + } + + private fun getPageLinks(linkHeader: String?): Links { + val links = HttpHeaderLink.parse(linkHeader) + return Links( + next = HttpHeaderLink.findByRelationType(links, "next")?.uri?.getQueryParameter( + "max_id" + ), + prev = HttpHeaderLink.findByRelationType(links, "prev")?.uri?.getQueryParameter( + "min_id" + ) + ) + } + + override fun getRefreshKey(state: PagingState): String? { + return state.anchorPosition?.let { anchorPosition -> + val anchorPage = state.closestPageToPosition(anchorPosition) + anchorPage?.prevKey ?: anchorPage?.nextKey + } + } + + companion object { + private const val TAG = "NotificationsPagingSource" + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsRepository.kt b/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsRepository.kt new file mode 100644 index 000000000..25c8458ac --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsRepository.kt @@ -0,0 +1,74 @@ +/* + * Copyright 2023 Tusky Contributors + * + * This file is a part of Tusky. + * + * This program is free software; you can redistribute it and/or modify it under the terms of the + * GNU General Public License as published by the Free Software Foundation; either version 3 of the + * License, or (at your option) any later version. + * + * Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even + * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General + * Public License for more details. + * + * You should have received a copy of the GNU General Public License along with Tusky; if not, + * see . + */ + +package com.keylesspalace.tusky.components.notifications + +import android.util.Log +import androidx.paging.InvalidatingPagingSourceFactory +import androidx.paging.Pager +import androidx.paging.PagingConfig +import androidx.paging.PagingData +import androidx.paging.PagingSource +import com.keylesspalace.tusky.entity.Notification +import com.keylesspalace.tusky.network.MastodonApi +import kotlinx.coroutines.flow.Flow +import okhttp3.ResponseBody +import retrofit2.Response +import javax.inject.Inject + +class NotificationsRepository @Inject constructor( + private val mastodonApi: MastodonApi +) { + private var factory: InvalidatingPagingSourceFactory? = null + + /** + * @return flow of Mastodon [Notification], excluding all types in [filter]. + * Notifications are loaded in [pageSize] increments. + */ + fun getNotificationsStream( + filter: Set, + pageSize: Int = PAGE_SIZE, + initialKey: String? = null + ): Flow> { + Log.d(TAG, "getNotificationsStream(), filtering: $filter") + + factory = InvalidatingPagingSourceFactory { + NotificationsPagingSource(mastodonApi, filter) + } + + return Pager( + config = PagingConfig(pageSize = pageSize), + initialKey = initialKey, + pagingSourceFactory = factory!! + ).flow + } + + /** Invalidate the active paging source, see [PagingSource.invalidate] */ + fun invalidate() { + factory?.invalidate() + } + + /** Clear notifications */ + suspend fun clearNotifications(): Response { + return mastodonApi.clearNotifications() + } + + companion object { + private const val TAG = "NotificationsRepository" + private const val PAGE_SIZE = 30 + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsViewModel.kt new file mode 100644 index 000000000..1c84dcadf --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsViewModel.kt @@ -0,0 +1,522 @@ +/* + * Copyright 2023 Tusky Contributors + * + * This file is a part of Tusky. + * + * This program is free software; you can redistribute it and/or modify it under the terms of the + * GNU General Public License as published by the Free Software Foundation; either version 3 of the + * License, or (at your option) any later version. + * + * Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even + * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General + * Public License for more details. + * + * You should have received a copy of the GNU General Public License along with Tusky; if not, + * see . + */ + +package com.keylesspalace.tusky.components.notifications + +import android.content.SharedPreferences +import android.util.Log +import androidx.annotation.StringRes +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import androidx.paging.PagingData +import androidx.paging.cachedIn +import androidx.paging.map +import com.keylesspalace.tusky.R +import com.keylesspalace.tusky.appstore.BlockEvent +import com.keylesspalace.tusky.appstore.EventHub +import com.keylesspalace.tusky.appstore.MuteConversationEvent +import com.keylesspalace.tusky.appstore.MuteEvent +import com.keylesspalace.tusky.appstore.PreferenceChangedEvent +import com.keylesspalace.tusky.components.timeline.util.ifExpected +import com.keylesspalace.tusky.db.AccountManager +import com.keylesspalace.tusky.entity.Notification +import com.keylesspalace.tusky.entity.Poll +import com.keylesspalace.tusky.settings.PrefKeys +import com.keylesspalace.tusky.usecase.TimelineCases +import com.keylesspalace.tusky.util.StatusDisplayOptions +import com.keylesspalace.tusky.util.deserialize +import com.keylesspalace.tusky.util.serialize +import com.keylesspalace.tusky.util.toViewData +import com.keylesspalace.tusky.viewdata.NotificationViewData +import com.keylesspalace.tusky.viewdata.StatusViewData +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import kotlinx.coroutines.rx3.asFlow +import kotlinx.coroutines.rx3.await +import retrofit2.HttpException +import javax.inject.Inject + +data class UiState( + /** Filtered notification types */ + val activeFilter: Set = emptySet(), + + /** True if the UI to filter and clear notifications should be shown */ + val showFilterOptions: Boolean = false, + + /** True if the FAB should be shown while scrolling */ + val showFabWhileScrolling: Boolean = true +) + +/** Preferences the UI reacts to */ +data class UiPrefs( + val showFabWhileScrolling: Boolean, + val showFilter: Boolean +) { + companion object { + /** Relevant preference keys. Changes to any of these trigger a display update */ + val prefKeys = setOf( + PrefKeys.FAB_HIDE, + PrefKeys.SHOW_NOTIFICATIONS_FILTER + ) + } +} + +/** Parent class for all UI actions, fallible or infallible. */ +sealed class UiAction + +/** Actions the user can trigger from the UI. These actions may fail. */ +sealed class FallibleUiAction : UiAction() { + /** Clear all notifications */ + object ClearNotifications : FallibleUiAction() +} + +/** + * Actions the user can trigger from the UI that either cannot fail, or if they do fail, + * do not show an error. + */ +sealed class InfallibleUiAction : UiAction() { + /** Apply a new filter to the notification list */ + // This saves the list to the local database, which triggers a refresh of the data. + // Saving the data can't fail, which is why this is infallible. Refreshing the + // data may fail, but that's handled by the paging system / adapter refresh logic. + data class ApplyFilter(val filter: Set) : InfallibleUiAction() + + /** + * User is leaving the fragment, save the ID of the visible notification. + * + * Infallible because if it fails there's nowhere to show the error, and nothing the user + * can do. + */ + data class SaveVisibleId(val visibleId: String) : InfallibleUiAction() +} + +/** Actions the user can trigger on an individual notification. These may fail. */ +sealed class NotificationAction : FallibleUiAction() { + data class AcceptFollowRequest(val accountId: String) : NotificationAction() + + data class RejectFollowRequest(val accountId: String) : NotificationAction() +} + +sealed class UiSuccess { + // These three are from menu items on the status. Currently they don't come to the + // viewModel as actions, they're noticed when events are posted. That will change, + // but for the moment we can still report them to the UI. Typically, receiving any + // of these three should trigger the UI to refresh. + + /** A user was blocked */ + object Block : UiSuccess() + + /** A user was muted */ + object Mute : UiSuccess() + + /** A conversation was muted */ + object MuteConversation : UiSuccess() +} + +/** The result of a successful action on a notification */ +sealed class NotificationActionSuccess( + /** String resource with an error message to show the user */ + @StringRes val msg: Int, + + /** + * The original action, in case additional information is required from it to display the + * message. + */ + open val action: NotificationAction +) : UiSuccess() { + data class AcceptFollowRequest(override val action: NotificationAction) : + NotificationActionSuccess(R.string.ui_success_accepted_follow_request, action) + data class RejectFollowRequest(override val action: NotificationAction) : + NotificationActionSuccess(R.string.ui_success_rejected_follow_request, action) + + companion object { + fun from(action: NotificationAction) = when (action) { + is NotificationAction.AcceptFollowRequest -> AcceptFollowRequest(action) + is NotificationAction.RejectFollowRequest -> RejectFollowRequest(action) + } + } +} + +/** Actions the user can trigger on an individual status */ +sealed class StatusAction( + open val statusViewData: StatusViewData.Concrete +) : FallibleUiAction() { + /** Set the bookmark state for a status */ + data class Bookmark(val state: Boolean, override val statusViewData: StatusViewData.Concrete) : + StatusAction(statusViewData) + + /** Set the favourite state for a status */ + data class Favourite(val state: Boolean, override val statusViewData: StatusViewData.Concrete) : + StatusAction(statusViewData) + + /** Set the reblog state for a status */ + data class Reblog(val state: Boolean, override val statusViewData: StatusViewData.Concrete) : + StatusAction(statusViewData) + + /** Vote in a poll */ + data class VoteInPoll( + val poll: Poll, + val choices: List, + override val statusViewData: StatusViewData.Concrete + ) : StatusAction(statusViewData) +} + +/** Changes to a status' visible state after API calls */ +sealed class StatusActionSuccess(open val action: StatusAction) : UiSuccess() { + data class Bookmark(override val action: StatusAction.Bookmark) : + StatusActionSuccess(action) + + data class Favourite(override val action: StatusAction.Favourite) : + StatusActionSuccess(action) + + data class Reblog(override val action: StatusAction.Reblog) : + StatusActionSuccess(action) + + data class VoteInPoll(override val action: StatusAction.VoteInPoll) : + StatusActionSuccess(action) + + companion object { + fun from(action: StatusAction) = when (action) { + is StatusAction.Bookmark -> Bookmark(action) + is StatusAction.Favourite -> Favourite(action) + is StatusAction.Reblog -> Reblog(action) + is StatusAction.VoteInPoll -> VoteInPoll(action) + } + } +} + +/** Errors from fallible view model actions that the UI will need to show */ +sealed class UiError( + /** The exception associated with the error */ + open val exception: Exception, + + /** String resource with an error message to show the user */ + @StringRes val message: Int, + + /** The action that failed. Can be resent to retry the action */ + open val action: UiAction? = null +) { + data class ClearNotifications(override val exception: Exception) : UiError( + exception, + R.string.ui_error_clear_notifications + ) + + data class Bookmark( + override val exception: Exception, + override val action: StatusAction.Bookmark + ) : UiError(exception, R.string.ui_error_bookmark, action) + + data class Favourite( + override val exception: Exception, + override val action: StatusAction.Favourite + ) : UiError(exception, R.string.ui_error_favourite, action) + + data class Reblog( + override val exception: Exception, + override val action: StatusAction.Reblog + ) : UiError(exception, R.string.ui_error_reblog, action) + + data class VoteInPoll( + override val exception: Exception, + override val action: StatusAction.VoteInPoll + ) : UiError(exception, R.string.ui_error_vote, action) + + data class AcceptFollowRequest( + override val exception: Exception, + override val action: NotificationAction.AcceptFollowRequest + ) : UiError(exception, R.string.ui_error_accept_follow_request, action) + + data class RejectFollowRequest( + override val exception: Exception, + override val action: NotificationAction.RejectFollowRequest + ) : UiError(exception, R.string.ui_error_reject_follow_request, action) + + companion object { + fun make(exception: Exception, action: FallibleUiAction) = when (action) { + is StatusAction.Bookmark -> Bookmark(exception, action) + is StatusAction.Favourite -> Favourite(exception, action) + is StatusAction.Reblog -> Reblog(exception, action) + is StatusAction.VoteInPoll -> VoteInPoll(exception, action) + is NotificationAction.AcceptFollowRequest -> AcceptFollowRequest(exception, action) + is NotificationAction.RejectFollowRequest -> RejectFollowRequest(exception, action) + FallibleUiAction.ClearNotifications -> ClearNotifications(exception) + } + } +} + +@OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class) +class NotificationsViewModel @Inject constructor( + private val repository: NotificationsRepository, + private val preferences: SharedPreferences, + private val accountManager: AccountManager, + private val timelineCases: TimelineCases, + private val eventHub: EventHub +) : ViewModel() { + + val uiState: StateFlow + + /** Flow of changes to statusDisplayOptions, for use by the UI */ + val statusDisplayOptions: StateFlow + + val pagingData: Flow> + + /** Flow of user actions received from the UI */ + private val uiAction = MutableSharedFlow() + + /** Flow of successful action results */ + // Note: These are a SharedFlow instead of a StateFlow because success or error state does not + // need to be retained. A message is shown once to a user and then dismissed. Re-collecting the + // flow (e.g., after a device orientation change) should not re-show the most recent success or + // error message, as it will be confusing to the user. + val uiSuccess = MutableSharedFlow() + + /** Flow of transient errors for the UI to present */ + val uiError = MutableSharedFlow() + + /** Accept UI actions in to actionStateFlow */ + val accept: (UiAction) -> Unit = { action -> + viewModelScope.launch { uiAction.emit(action) } + } + + init { + // Handle changes to notification filters + val notificationFilter = uiAction + .filterIsInstance() + .distinctUntilChanged() + // Save each change back to the active account + .onEach { action -> + Log.d(TAG, "notificationFilter: $action") + accountManager.activeAccount?.let { account -> + account.notificationsFilter = serialize(action.filter) + accountManager.saveAccount(account) + } + } + // Load the initial filter from the active account + .onStart { + emit( + InfallibleUiAction.ApplyFilter( + filter = deserialize(accountManager.activeAccount?.notificationsFilter) + ) + ) + } + + // Save the visible notification ID + viewModelScope.launch { + uiAction + .filterIsInstance() + .distinctUntilChanged() + .collectLatest { action -> + Log.d(TAG, "Saving visible ID: ${action.visibleId}") + accountManager.activeAccount?.let { account -> + account.lastNotificationId = action.visibleId + accountManager.saveAccount(account) + } + } + } + + // Set initial status display options from the user's preferences. + // + // Then collect future preference changes and emit new values in to + // statusDisplayOptions if necessary. + statusDisplayOptions = MutableStateFlow( + StatusDisplayOptions.from( + preferences, + accountManager.activeAccount!! + ) + ) + + viewModelScope.launch { + eventHub.events.asFlow() + .filterIsInstance() + .filter { StatusDisplayOptions.prefKeys.contains(it.preferenceKey) } + .map { + statusDisplayOptions.value.make( + preferences, + it.preferenceKey, + accountManager.activeAccount!! + ) + } + .collect { + statusDisplayOptions.emit(it) + } + } + + // Handle UiAction.ClearNotifications + viewModelScope.launch { + uiAction.filterIsInstance() + .collectLatest { + try { + repository.clearNotifications().apply { + if (this.isSuccessful) { + repository.invalidate() + } else { + uiError.emit(UiError.make(HttpException(this), it)) + } + } + } catch (e: Exception) { + ifExpected(e) { uiError.emit(UiError.make(e, it)) } + } + } + } + + // Handle NotificationAction.* + viewModelScope.launch { + uiAction.filterIsInstance() + .debounce(DEBOUNCE_TIMEOUT_MS) + .collect { action -> + try { + when (action) { + is NotificationAction.AcceptFollowRequest -> + timelineCases.acceptFollowRequest(action.accountId).await() + is NotificationAction.RejectFollowRequest -> + timelineCases.rejectFollowRequest(action.accountId).await() + } + uiSuccess.emit(NotificationActionSuccess.from(action)) + } catch (e: Exception) { + ifExpected(e) { uiError.emit(UiError.make(e, action)) } + } + } + } + + // Handle StatusAction.* + viewModelScope.launch { + uiAction.filterIsInstance() + .debounce(DEBOUNCE_TIMEOUT_MS) // avoid double-taps + .collect { action -> + try { + when (action) { + is StatusAction.Bookmark -> + timelineCases.bookmark( + action.statusViewData.actionableId, + action.state + ).await() + is StatusAction.Favourite -> + timelineCases.favourite( + action.statusViewData.actionableId, + action.state + ).await() + is StatusAction.Reblog -> + timelineCases.reblog( + action.statusViewData.actionableId, + action.state + ).await() + is StatusAction.VoteInPoll -> + timelineCases.voteInPoll( + action.statusViewData.actionableId, + action.poll.id, + action.choices + ).await() + } + uiSuccess.emit(StatusActionSuccess.from(action)) + } catch (e: Exception) { + ifExpected(e) { uiError.emit(UiError.make(e, action)) } + } + } + } + + // Handle events that should refresh the list + viewModelScope.launch { + eventHub.events.asFlow().collectLatest { + when (it) { + is BlockEvent -> uiSuccess.emit(UiSuccess.Block) + is MuteEvent -> uiSuccess.emit(UiSuccess.Mute) + is MuteConversationEvent -> uiSuccess.emit(UiSuccess.MuteConversation) + } + } + } + + // The database stores "0" as the last notification ID if notifications have not been + // fetched. Convert to null to ensure a full fetch in this case + val lastNotificationId = when (val id = accountManager.activeAccount?.lastNotificationId) { + "0" -> null + else -> id + } + Log.d(TAG, "Restoring at $lastNotificationId") + + pagingData = notificationFilter + .flatMapLatest { action -> + getNotifications(filters = action.filter, initialKey = lastNotificationId) + } + .cachedIn(viewModelScope) + + uiState = combine(notificationFilter, getUiPrefs()) { filter, prefs -> + UiState( + activeFilter = filter.filter, + showFilterOptions = prefs.showFilter, + showFabWhileScrolling = prefs.showFabWhileScrolling + ) + }.stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(stopTimeoutMillis = 5000), + initialValue = UiState() + ) + } + + private fun getNotifications( + filters: Set, + initialKey: String? = null + ): Flow> { + return repository.getNotificationsStream(filter = filters, initialKey = initialKey) + .map { pagingData -> + pagingData.map { notification -> + notification.toViewData( + isShowingContent = statusDisplayOptions.value.showSensitiveMedia || + !(notification.status?.actionableStatus?.sensitive ?: false), + isExpanded = statusDisplayOptions.value.openSpoiler, + isCollapsed = true + ) + } + } + } + + /** + * @return Flow of relevant preferences that change the UI + */ + // TODO: Preferences should be in a repository + private fun getUiPrefs() = eventHub.events.asFlow() + .filterIsInstance() + .filter { UiPrefs.prefKeys.contains(it.preferenceKey) } + .map { toPrefs() } + .onStart { emit(toPrefs()) } + + private fun toPrefs() = UiPrefs( + showFabWhileScrolling = !preferences.getBoolean(PrefKeys.FAB_HIDE, false), + showFilter = preferences.getBoolean(PrefKeys.SHOW_NOTIFICATIONS_FILTER, true) + ) + + companion object { + private const val TAG = "NotificationsViewModel" + private const val DEBOUNCE_TIMEOUT_MS = 500L + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/notifications/PushNotificationHelper.kt b/app/src/main/java/com/keylesspalace/tusky/components/notifications/PushNotificationHelper.kt index cf1dd4384..6745579ed 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/notifications/PushNotificationHelper.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/notifications/PushNotificationHelper.kt @@ -150,7 +150,7 @@ fun disableAllNotifications(context: Context, accountManager: AccountManager) { private fun buildSubscriptionData(context: Context, account: AccountEntity): Map = buildMap { - Notification.Type.asList.forEach { + Notification.Type.visibleTypes.forEach { put("data[alerts][${it.presentation}]", NotificationHelper.filterNotification(account, it, context)) } } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/notifications/StatusNotificationViewHolder.kt b/app/src/main/java/com/keylesspalace/tusky/components/notifications/StatusNotificationViewHolder.kt new file mode 100644 index 000000000..402f38725 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/notifications/StatusNotificationViewHolder.kt @@ -0,0 +1,385 @@ +/* + * Copyright 2023 Tusky Contributors + * + * This file is a part of Tusky. + * + * This program is free software; you can redistribute it and/or modify it under the terms of the + * GNU General Public License as published by the Free Software Foundation; either version 3 of the + * License, or (at your option) any later version. + * + * Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even + * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General + * Public License for more details. + * + * You should have received a copy of the GNU General Public License along with Tusky; if not, + * see . + */ + +package com.keylesspalace.tusky.components.notifications + +import android.content.Context +import android.graphics.PorterDuff +import android.graphics.Typeface +import android.graphics.drawable.Drawable +import android.text.InputFilter +import android.text.SpannableStringBuilder +import android.text.Spanned +import android.text.TextUtils +import android.text.format.DateUtils +import android.text.style.StyleSpan +import android.view.View +import androidx.annotation.ColorRes +import androidx.annotation.DrawableRes +import androidx.core.content.ContextCompat +import androidx.recyclerview.widget.RecyclerView +import at.connyduck.sparkbutton.helpers.Utils +import com.bumptech.glide.Glide +import com.keylesspalace.tusky.R +import com.keylesspalace.tusky.adapter.StatusBaseViewHolder +import com.keylesspalace.tusky.databinding.ItemStatusNotificationBinding +import com.keylesspalace.tusky.entity.Emoji +import com.keylesspalace.tusky.entity.Notification +import com.keylesspalace.tusky.interfaces.LinkListener +import com.keylesspalace.tusky.interfaces.StatusActionListener +import com.keylesspalace.tusky.util.AbsoluteTimeFormatter +import com.keylesspalace.tusky.util.SmartLengthInputFilter +import com.keylesspalace.tusky.util.StatusDisplayOptions +import com.keylesspalace.tusky.util.emojify +import com.keylesspalace.tusky.util.getRelativeTimeSpanString +import com.keylesspalace.tusky.util.loadAvatar +import com.keylesspalace.tusky.util.setClickableText +import com.keylesspalace.tusky.util.unicodeWrap +import com.keylesspalace.tusky.viewdata.NotificationViewData +import com.keylesspalace.tusky.viewdata.StatusViewData +import java.util.Date + +/** + * View holder for a status with an activity to be notified about (posted, boosted, + * favourited, or edited, per [NotificationViewKind.from]). + * + * Shows a line with the activity, and who initiated the activity. Clicking this should + * go to the profile page for the initiator. + * + * Displays the original status below that. Clicking this should go to the original + * status in context. + */ +internal class StatusNotificationViewHolder( + private val binding: ItemStatusNotificationBinding, + private val statusActionListener: StatusActionListener, + private val notificationActionListener: NotificationActionListener, + private val absoluteTimeFormatter: AbsoluteTimeFormatter +) : NotificationsPagingAdapter.ViewHolder, RecyclerView.ViewHolder(binding.root) { + private val avatarRadius48dp = itemView.context.resources.getDimensionPixelSize( + R.dimen.avatar_radius_48dp + ) + private val avatarRadius36dp = itemView.context.resources.getDimensionPixelSize( + R.dimen.avatar_radius_36dp + ) + private val avatarRadius24dp = itemView.context.resources.getDimensionPixelSize( + R.dimen.avatar_radius_24dp + ) + + override fun bind( + viewData: NotificationViewData, + payloads: List<*>?, + statusDisplayOptions: StatusDisplayOptions + ) { + val statusViewData = viewData.statusViewData + if (payloads.isNullOrEmpty()) { + // Hide null statuses. Shouldn't happen according to the spec, but some servers + // have been seen to do this (https://github.com/tuskyapp/Tusky/issues/2252) + if (statusViewData == null) { + showNotificationContent(false) + } else { + showNotificationContent(true) + val (_, _, account, _, _, _, _, createdAt) = statusViewData.actionable + setDisplayName(account.name, account.emojis, statusDisplayOptions.animateEmojis) + setUsername(account.username) + setCreatedAt(createdAt, statusDisplayOptions.useAbsoluteTime) + if (viewData.type == Notification.Type.STATUS || + viewData.type == Notification.Type.UPDATE + ) { + setAvatar( + account.avatar, + account.bot, + statusDisplayOptions.animateAvatars, + statusDisplayOptions.showBotOverlay + ) + } else { + setAvatars( + account.avatar, + viewData.account.avatar, + statusDisplayOptions.animateAvatars + ) + } + + binding.notificationContainer.setOnClickListener { + notificationActionListener.onViewThreadForStatus(statusViewData.status) + } + binding.notificationContent.setOnClickListener { + notificationActionListener.onViewThreadForStatus(statusViewData.status) + } + binding.notificationTopText.setOnClickListener { + notificationActionListener.onViewAccount(viewData.account.id) + } + } + setMessage(viewData, statusActionListener, statusDisplayOptions.animateEmojis) + } else { + for (item in payloads) { + if (StatusBaseViewHolder.Key.KEY_CREATED == item && statusViewData != null) { + setCreatedAt( + statusViewData.status.actionableStatus.createdAt, + statusDisplayOptions.useAbsoluteTime + ) + } + } + } + } + + private fun showNotificationContent(show: Boolean) { + binding.statusNameBar.visibility = if (show) View.VISIBLE else View.GONE + binding.notificationContentWarningDescription.visibility = + if (show) View.VISIBLE else View.GONE + binding.notificationContentWarningButton.visibility = + if (show) View.VISIBLE else View.GONE + binding.notificationContent.visibility = if (show) View.VISIBLE else View.GONE + binding.notificationStatusAvatar.visibility = if (show) View.VISIBLE else View.GONE + binding.notificationNotificationAvatar.visibility = if (show) View.VISIBLE else View.GONE + } + + private fun setDisplayName(name: String, emojis: List?, animateEmojis: Boolean) { + val emojifiedName = name.emojify(emojis, binding.statusDisplayName, animateEmojis) + binding.statusDisplayName.text = emojifiedName + } + + private fun setUsername(name: String) { + val context = binding.statusUsername.context + val format = context.getString(R.string.post_username_format) + val usernameText = String.format(format, name) + binding.statusUsername.text = usernameText + } + + private fun setCreatedAt(createdAt: Date?, useAbsoluteTime: Boolean) { + if (useAbsoluteTime) { + binding.statusMetaInfo.text = absoluteTimeFormatter.format(createdAt, true) + } else { + // This is the visible timestampInfo. + val readout: String + /* This one is for screen-readers. Frequently, they would mispronounce timestamps like "17m" + * as 17 meters instead of minutes. */ + val readoutAloud: CharSequence + if (createdAt != null) { + val then = createdAt.time + val now = Date().time + readout = getRelativeTimeSpanString(binding.statusMetaInfo.context, then, now) + readoutAloud = DateUtils.getRelativeTimeSpanString( + then, + now, + DateUtils.SECOND_IN_MILLIS, + DateUtils.FORMAT_ABBREV_RELATIVE + ) + } else { + // unknown minutes~ + readout = "?m" + readoutAloud = "? minutes" + } + binding.statusMetaInfo.text = readout + binding.statusMetaInfo.contentDescription = readoutAloud + } + } + + private fun getIconWithColor( + context: Context, + @DrawableRes drawable: Int, + @ColorRes color: Int + ): Drawable? { + val icon = ContextCompat.getDrawable(context, drawable) + icon?.setColorFilter(context.getColor(color), PorterDuff.Mode.SRC_ATOP) + return icon + } + + private fun setAvatar(statusAvatarUrl: String?, isBot: Boolean, animateAvatars: Boolean, showBotOverlay: Boolean) { + binding.notificationStatusAvatar.setPaddingRelative(0, 0, 0, 0) + loadAvatar( + statusAvatarUrl, + binding.notificationStatusAvatar, + avatarRadius48dp, + animateAvatars + ) + if (showBotOverlay && isBot) { + binding.notificationNotificationAvatar.visibility = View.VISIBLE + Glide.with(binding.notificationNotificationAvatar) + .load(R.drawable.bot_badge) + .into(binding.notificationNotificationAvatar) + } else { + binding.notificationNotificationAvatar.visibility = View.GONE + } + } + + private fun setAvatars(statusAvatarUrl: String?, notificationAvatarUrl: String?, animateAvatars: Boolean) { + val padding = Utils.dpToPx(binding.notificationStatusAvatar.context, 12) + binding.notificationStatusAvatar.setPaddingRelative(0, 0, padding, padding) + loadAvatar( + statusAvatarUrl, + binding.notificationStatusAvatar, + avatarRadius36dp, + animateAvatars + ) + binding.notificationNotificationAvatar.visibility = View.VISIBLE + loadAvatar( + notificationAvatarUrl, + binding.notificationNotificationAvatar, + avatarRadius24dp, + animateAvatars + ) + } + + fun setMessage( + notificationViewData: NotificationViewData, + listener: LinkListener, + animateEmojis: Boolean + ) { + val statusViewData = notificationViewData.statusViewData + val displayName = notificationViewData.account.name.unicodeWrap() + val type = notificationViewData.type + val context = binding.notificationTopText.context + val format: String + val icon: Drawable? + when (type) { + Notification.Type.FAVOURITE -> { + icon = getIconWithColor(context, R.drawable.ic_star_24dp, R.color.tusky_orange) + format = context.getString(R.string.notification_favourite_format) + } + Notification.Type.REBLOG -> { + icon = getIconWithColor(context, R.drawable.ic_repeat_24dp, R.color.tusky_blue) + format = context.getString(R.string.notification_reblog_format) + } + Notification.Type.STATUS -> { + icon = getIconWithColor(context, R.drawable.ic_home_24dp, R.color.tusky_blue) + format = context.getString(R.string.notification_subscription_format) + } + Notification.Type.UPDATE -> { + icon = getIconWithColor(context, R.drawable.ic_edit_24dp, R.color.tusky_blue) + format = context.getString(R.string.notification_update_format) + } + else -> { + icon = getIconWithColor(context, R.drawable.ic_star_24dp, R.color.tusky_orange) + format = context.getString(R.string.notification_favourite_format) + } + } + binding.notificationTopText.setCompoundDrawablesWithIntrinsicBounds( + icon, + null, + null, + null + ) + val wholeMessage = String.format(format, displayName) + val str = SpannableStringBuilder(wholeMessage) + val displayNameIndex = format.indexOf("%s") + str.setSpan( + StyleSpan(Typeface.BOLD), + displayNameIndex, + displayNameIndex + displayName.length, + Spanned.SPAN_EXCLUSIVE_EXCLUSIVE + ) + val emojifiedText = str.emojify( + notificationViewData.account.emojis, + binding.notificationTopText, + animateEmojis + ) + binding.notificationTopText.text = emojifiedText + if (statusViewData != null) { + val hasSpoiler = !TextUtils.isEmpty(statusViewData.status.spoilerText) + binding.notificationContentWarningDescription.visibility = + if (hasSpoiler) View.VISIBLE else View.GONE + binding.notificationContentWarningButton.visibility = + if (hasSpoiler) View.VISIBLE else View.GONE + if (statusViewData.isExpanded) { + binding.notificationContentWarningButton.setText( + R.string.post_content_warning_show_less + ) + } else { + binding.notificationContentWarningButton.setText( + R.string.post_content_warning_show_more + ) + } + binding.notificationContentWarningButton.setOnClickListener { + if (bindingAdapterPosition != RecyclerView.NO_POSITION) { + notificationActionListener.onExpandedChange( + !statusViewData.isExpanded, + bindingAdapterPosition + ) + } + binding.notificationContent.visibility = + if (statusViewData.isExpanded) View.GONE else View.VISIBLE + } + setupContentAndSpoiler(listener, statusViewData, animateEmojis) + } + } + + private fun setupContentAndSpoiler( + listener: LinkListener, + statusViewData: StatusViewData.Concrete, + animateEmojis: Boolean + ) { + val shouldShowContentIfSpoiler = statusViewData.isExpanded + val hasSpoiler = !TextUtils.isEmpty(statusViewData.status.spoilerText) + if (!shouldShowContentIfSpoiler && hasSpoiler) { + binding.notificationContent.visibility = View.GONE + } else { + binding.notificationContent.visibility = View.VISIBLE + } + val content = statusViewData.content + val emojis = statusViewData.actionable.emojis + if (statusViewData.isCollapsible && (statusViewData.isExpanded || !hasSpoiler)) { + binding.buttonToggleNotificationContent.setOnClickListener { + val position = bindingAdapterPosition + if (position != RecyclerView.NO_POSITION) { + notificationActionListener.onNotificationContentCollapsedChange( + !statusViewData.isCollapsed, + position + ) + } + } + binding.buttonToggleNotificationContent.visibility = View.VISIBLE + if (statusViewData.isCollapsed) { + binding.buttonToggleNotificationContent.setText( + R.string.post_content_warning_show_more + ) + binding.notificationContent.filters = COLLAPSE_INPUT_FILTER + } else { + binding.buttonToggleNotificationContent.setText( + R.string.post_content_warning_show_less + ) + binding.notificationContent.filters = NO_INPUT_FILTER + } + } else { + binding.buttonToggleNotificationContent.visibility = View.GONE + binding.notificationContent.filters = NO_INPUT_FILTER + } + val emojifiedText = + content.emojify( + emojis, + binding.notificationContent, + animateEmojis + ) + setClickableText( + binding.notificationContent, + emojifiedText, + statusViewData.actionable.mentions, + statusViewData.actionable.tags, + listener + ) + val emojifiedContentWarning: CharSequence = statusViewData.spoilerText.emojify( + statusViewData.actionable.emojis, + binding.notificationContentWarningDescription, + animateEmojis + ) + binding.notificationContentWarningDescription.text = emojifiedContentWarning + } + + companion object { + private val COLLAPSE_INPUT_FILTER = arrayOf(SmartLengthInputFilter) + private val NO_INPUT_FILTER = arrayOfNulls(0) + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/notifications/StatusViewHolder.kt b/app/src/main/java/com/keylesspalace/tusky/components/notifications/StatusViewHolder.kt new file mode 100644 index 000000000..c719c084a --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/notifications/StatusViewHolder.kt @@ -0,0 +1,60 @@ +/* + * Copyright 2023 Tusky Contributors + * + * This file is a part of Tusky. + * + * This program is free software; you can redistribute it and/or modify it under the terms of the + * GNU General Public License as published by the Free Software Foundation; either version 3 of the + * License, or (at your option) any later version. + * + * Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even + * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General + * Public License for more details. + * + * You should have received a copy of the GNU General Public License along with Tusky; if not, + * see . + */ + +package com.keylesspalace.tusky.components.notifications + +import com.keylesspalace.tusky.adapter.StatusViewHolder +import com.keylesspalace.tusky.databinding.ItemStatusBinding +import com.keylesspalace.tusky.entity.Notification +import com.keylesspalace.tusky.interfaces.StatusActionListener +import com.keylesspalace.tusky.util.StatusDisplayOptions +import com.keylesspalace.tusky.viewdata.NotificationViewData + +internal class StatusViewHolder( + binding: ItemStatusBinding, + private val statusActionListener: StatusActionListener, + private val accountId: String +) : NotificationsPagingAdapter.ViewHolder, StatusViewHolder(binding.root) { + + override fun bind( + viewData: NotificationViewData, + payloads: List<*>?, + statusDisplayOptions: StatusDisplayOptions + ) { + val statusViewData = viewData.statusViewData + if (statusViewData == null) { + // Hide null statuses. Shouldn't happen according to the spec, but some servers + // have been seen to do this (https://github.com/tuskyapp/Tusky/issues/2252) + showStatusContent(false) + } else { + if (payloads.isNullOrEmpty()) { + showStatusContent(true) + } + setupWithStatus( + statusViewData, + statusActionListener, + statusDisplayOptions, + payloads?.firstOrNull() + ) + } + if (viewData.type == Notification.Type.POLL) { + setPollInfo(accountId == viewData.account.id) + } else { + hideStatusInfo() + } + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/report/fragments/ReportStatusesFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/report/fragments/ReportStatusesFragment.kt index f65e29c3a..f15f19629 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/report/fragments/ReportStatusesFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/report/fragments/ReportStatusesFragment.kt @@ -156,7 +156,9 @@ class ReportStatusesFragment : confirmReblogs = preferences.getBoolean("confirmReblogs", true), confirmFavourites = preferences.getBoolean("confirmFavourites", false), hideStats = preferences.getBoolean(PrefKeys.WELLBEING_HIDE_STATS_POSTS, false), - animateEmojis = preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false) + animateEmojis = preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false), + showSensitiveMedia = accountManager.activeAccount!!.alwaysShowSensitiveMedia, + openSpoiler = accountManager.activeAccount!!.alwaysOpenSpoiler ) adapter = StatusesAdapter(statusDisplayOptions, viewModel.statusViewState, this) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/search/fragments/SearchStatusesFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/search/fragments/SearchStatusesFragment.kt index 12aeaf81b..1b3c39f95 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/search/fragments/SearchStatusesFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/search/fragments/SearchStatusesFragment.kt @@ -48,6 +48,7 @@ import com.keylesspalace.tusky.components.compose.ComposeActivity.ComposeOptions import com.keylesspalace.tusky.components.report.ReportActivity import com.keylesspalace.tusky.components.search.adapter.SearchStatusesAdapter import com.keylesspalace.tusky.db.AccountEntity +import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.entity.Attachment import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.entity.Status.Mention @@ -62,8 +63,11 @@ import com.keylesspalace.tusky.viewdata.AttachmentViewData import com.keylesspalace.tusky.viewdata.StatusViewData import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.launch +import javax.inject.Inject class SearchStatusesFragment : SearchFragment(), StatusActionListener { + @Inject + lateinit var accountManager: AccountManager override val data: Flow> get() = viewModel.statusesFlow @@ -83,7 +87,9 @@ class SearchStatusesFragment : SearchFragment(), Status confirmReblogs = preferences.getBoolean("confirmReblogs", true), confirmFavourites = preferences.getBoolean("confirmFavourites", false), hideStats = preferences.getBoolean(PrefKeys.WELLBEING_HIDE_STATS_POSTS, false), - animateEmojis = preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false) + animateEmojis = preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false), + showSensitiveMedia = accountManager.activeAccount!!.alwaysShowSensitiveMedia, + openSpoiler = accountManager.activeAccount!!.alwaysOpenSpoiler ) binding.searchRecyclerView.addItemDecoration(DividerItemDecoration(binding.searchRecyclerView.context, DividerItemDecoration.VERTICAL)) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt index 36d20e687..317d8df1b 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt @@ -191,7 +191,9 @@ class TimelineFragment : confirmReblogs = preferences.getBoolean(PrefKeys.CONFIRM_REBLOGS, true), confirmFavourites = preferences.getBoolean(PrefKeys.CONFIRM_FAVOURITES, false), hideStats = preferences.getBoolean(PrefKeys.WELLBEING_HIDE_STATS_POSTS, false), - animateEmojis = preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false) + animateEmojis = preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false), + showSensitiveMedia = accountManager.activeAccount!!.alwaysShowSensitiveMedia, + openSpoiler = accountManager.activeAccount!!.alwaysOpenSpoiler ) adapter = TimelinePagingAdapter( statusDisplayOptions, @@ -226,16 +228,16 @@ class TimelineFragment : is LoadState.NotLoading -> { if (loadState.append is LoadState.NotLoading && loadState.source.refresh is LoadState.NotLoading) { binding.statusView.show() - binding.statusView.setup(R.drawable.elephant_friend_empty, R.string.message_empty, null) + binding.statusView.setup(R.drawable.elephant_friend_empty, R.string.message_empty) } } is LoadState.Error -> { binding.statusView.show() if ((loadState.refresh as LoadState.Error).error is IOException) { - binding.statusView.setup(R.drawable.elephant_offline, R.string.error_network, null) + binding.statusView.setup(R.drawable.elephant_offline, R.string.error_network) } else { - binding.statusView.setup(R.drawable.elephant_error, R.string.error_generic, null) + binding.statusView.setup(R.drawable.elephant_error, R.string.error_generic) } } is LoadState.Loading -> { diff --git a/app/src/main/java/com/keylesspalace/tusky/components/viewthread/ViewThreadFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/viewthread/ViewThreadFragment.kt index 4baa0ff12..c780ffeb1 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/viewthread/ViewThreadFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/viewthread/ViewThreadFragment.kt @@ -112,7 +112,9 @@ class ViewThreadFragment : confirmReblogs = preferences.getBoolean("confirmReblogs", true), confirmFavourites = preferences.getBoolean("confirmFavourites", false), hideStats = preferences.getBoolean(PrefKeys.WELLBEING_HIDE_STATS_POSTS, false), - animateEmojis = preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false) + animateEmojis = preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false), + showSensitiveMedia = accountManager.activeAccount!!.alwaysShowSensitiveMedia, + openSpoiler = accountManager.activeAccount!!.alwaysOpenSpoiler ) adapter = ThreadAdapter(statusDisplayOptions, this) } diff --git a/app/src/main/java/com/keylesspalace/tusky/db/AccountEntity.kt b/app/src/main/java/com/keylesspalace/tusky/db/AccountEntity.kt index 852088f33..418c77e32 100644 --- a/app/src/main/java/com/keylesspalace/tusky/db/AccountEntity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/db/AccountEntity.kt @@ -64,6 +64,11 @@ data class AccountEntity( var alwaysShowSensitiveMedia: Boolean = false, /** True if content behind a content warning is shown by default */ var alwaysOpenSpoiler: Boolean = false, + + /** + * True if the "Download media previews" preference is true. This implies + * that media previews are shown as well as downloaded. + */ var mediaPreviewEnabled: Boolean = true, var lastNotificationId: String = "0", var activeNotifications: String = "[]", diff --git a/app/src/main/java/com/keylesspalace/tusky/di/FragmentBuildersModule.kt b/app/src/main/java/com/keylesspalace/tusky/di/FragmentBuildersModule.kt index 3ad18fcac..aee1feab4 100644 --- a/app/src/main/java/com/keylesspalace/tusky/di/FragmentBuildersModule.kt +++ b/app/src/main/java/com/keylesspalace/tusky/di/FragmentBuildersModule.kt @@ -21,6 +21,7 @@ import com.keylesspalace.tusky.components.account.media.AccountMediaFragment import com.keylesspalace.tusky.components.accountlist.AccountListFragment import com.keylesspalace.tusky.components.conversation.ConversationsFragment import com.keylesspalace.tusky.components.instancemute.fragment.InstanceListFragment +import com.keylesspalace.tusky.components.notifications.NotificationsFragment import com.keylesspalace.tusky.components.preference.AccountPreferencesFragment import com.keylesspalace.tusky.components.preference.NotificationPreferencesFragment import com.keylesspalace.tusky.components.preference.PreferencesFragment @@ -34,7 +35,6 @@ import com.keylesspalace.tusky.components.timeline.TimelineFragment import com.keylesspalace.tusky.components.trending.TrendingFragment import com.keylesspalace.tusky.components.viewthread.ViewThreadFragment import com.keylesspalace.tusky.components.viewthread.edits.ViewEditsFragment -import com.keylesspalace.tusky.fragment.NotificationsFragment import dagger.Module import dagger.android.ContributesAndroidInjector diff --git a/app/src/main/java/com/keylesspalace/tusky/di/ViewModelFactory.kt b/app/src/main/java/com/keylesspalace/tusky/di/ViewModelFactory.kt index f852d97bb..e3ce3a3e6 100644 --- a/app/src/main/java/com/keylesspalace/tusky/di/ViewModelFactory.kt +++ b/app/src/main/java/com/keylesspalace/tusky/di/ViewModelFactory.kt @@ -1,3 +1,20 @@ +/* + * Copyright 2023 Tusky Contributors + * + * This file is a part of Tusky. + * + * This program is free software; you can redistribute it and/or modify it under the terms of the + * GNU General Public License as published by the Free Software Foundation; either version 3 of the + * License, or (at your option) any later version. + * + * Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even + * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General + * Public License for more details. + * + * You should have received a copy of the GNU General Public License along with Tusky; if not, + * see . + */ + // from https://proandroiddev.com/viewmodel-with-dagger2-architecture-components-2e06f06c9455 package com.keylesspalace.tusky.di @@ -13,6 +30,7 @@ import com.keylesspalace.tusky.components.conversation.ConversationsViewModel import com.keylesspalace.tusky.components.drafts.DraftsViewModel import com.keylesspalace.tusky.components.followedtags.FollowedTagsViewModel import com.keylesspalace.tusky.components.login.LoginWebViewViewModel +import com.keylesspalace.tusky.components.notifications.NotificationsViewModel import com.keylesspalace.tusky.components.report.ReportViewModel import com.keylesspalace.tusky.components.scheduled.ScheduledStatusViewModel import com.keylesspalace.tusky.components.search.SearchViewModel @@ -145,6 +163,11 @@ abstract class ViewModelModule { @ViewModelKey(ListsForAccountViewModel::class) internal abstract fun listsForAccountViewModel(viewModel: ListsForAccountViewModel): ViewModel + @Binds + @IntoMap + @ViewModelKey(NotificationsViewModel::class) + internal abstract fun notificationsViewModel(viewModel: NotificationsViewModel): ViewModel + @Binds @IntoMap @ViewModelKey(TrendingViewModel::class) diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/Notification.kt b/app/src/main/java/com/keylesspalace/tusky/entity/Notification.kt index b058c4c10..1bad6697b 100644 --- a/app/src/main/java/com/keylesspalace/tusky/entity/Notification.kt +++ b/app/src/main/java/com/keylesspalace/tusky/entity/Notification.kt @@ -15,11 +15,13 @@ package com.keylesspalace.tusky.entity +import androidx.annotation.StringRes import com.google.gson.JsonDeserializationContext import com.google.gson.JsonDeserializer import com.google.gson.JsonElement import com.google.gson.JsonParseException import com.google.gson.annotations.JsonAdapter +import com.keylesspalace.tusky.R data class Notification( val type: Type, @@ -29,23 +31,42 @@ data class Notification( val report: Report?, ) { + /** From https://docs.joinmastodon.org/entities/Notification/#type */ @JsonAdapter(NotificationTypeAdapter::class) - enum class Type(val presentation: String) { - UNKNOWN("unknown"), - MENTION("mention"), - REBLOG("reblog"), - FAVOURITE("favourite"), - FOLLOW("follow"), - FOLLOW_REQUEST("follow_request"), - POLL("poll"), - STATUS("status"), - SIGN_UP("admin.sign_up"), - UPDATE("update"), - REPORT("admin.report"), - ; + enum class Type(val presentation: String, @StringRes val uiString: Int) { + UNKNOWN("unknown", R.string.notification_unknown_name), + + /** Someone mentioned you */ + MENTION("mention", R.string.notification_mention_name), + + /** Someone boosted one of your statuses */ + REBLOG("reblog", R.string.notification_boost_name), + + /** Someone favourited one of your statuses */ + FAVOURITE("favourite", R.string.notification_favourite_name), + + /** Someone followed you */ + FOLLOW("follow", R.string.notification_follow_name), + + /** Someone requested to follow you */ + FOLLOW_REQUEST("follow_request", R.string.notification_follow_request_name), + + /** A poll you have voted in or created has ended */ + POLL("poll", R.string.notification_poll_name), + + /** Someone you enabled notifications for has posted a status */ + STATUS("status", R.string.notification_subscription_name), + + /** Someone signed up (optionally sent to admins) */ + SIGN_UP("admin.sign_up", R.string.notification_sign_up_name), + + /** A status you interacted with has been updated */ + UPDATE("update", R.string.notification_update_name), + + /** A new report has been filed */ + REPORT("admin.report", R.string.notification_report_name); companion object { - @JvmStatic fun byString(s: String): Type { values().forEach { @@ -54,7 +75,9 @@ data class Notification( } return UNKNOWN } - val asList = listOf(MENTION, REBLOG, FAVOURITE, FOLLOW, FOLLOW_REQUEST, POLL, STATUS, SIGN_UP, UPDATE, REPORT) + + /** Notification types for UI display (omits UNKNOWN) */ + val visibleTypes = listOf(MENTION, REBLOG, FAVOURITE, FOLLOW, FOLLOW_REQUEST, POLL, STATUS, SIGN_UP, UPDATE, REPORT) } override fun toString(): String { @@ -86,9 +109,6 @@ data class Notification( } } - /** Helper for Java */ - fun copyWithStatus(status: Status?): Notification = copy(status = status) - // for Pleroma compatibility that uses Mention type fun rewriteToStatusTypeIfNeeded(accountId: String): Notification { if (type == Type.MENTION && status != null) { diff --git a/app/src/main/java/com/keylesspalace/tusky/fragment/NotificationsFragment.java b/app/src/main/java/com/keylesspalace/tusky/fragment/NotificationsFragment.java deleted file mode 100644 index 24d026ccb..000000000 --- a/app/src/main/java/com/keylesspalace/tusky/fragment/NotificationsFragment.java +++ /dev/null @@ -1,1273 +0,0 @@ -/* Copyright 2017 Andrew Dawson - * - * This file is a part of Tusky. - * - * This program is free software; you can redistribute it and/or modify it under the terms of the - * GNU General Public License as published by the Free Software Foundation; either version 3 of the - * License, or (at your option) any later version. - * - * Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even - * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General - * Public License for more details. - * - * You should have received a copy of the GNU General Public License along with Tusky; if not, - * see . */ - -package com.keylesspalace.tusky.fragment; - -import static com.keylesspalace.tusky.util.StringUtils.isLessThan; -import static autodispose2.AutoDispose.autoDisposable; -import static autodispose2.androidx.lifecycle.AndroidLifecycleScopeProvider.from; - -import android.app.Activity; -import android.content.Context; -import android.content.DialogInterface; -import android.content.SharedPreferences; -import android.os.Bundle; -import android.util.Log; -import android.util.SparseBooleanArray; -import android.view.LayoutInflater; -import android.view.Menu; -import android.view.MenuInflater; -import android.view.MenuItem; -import android.view.View; -import android.view.ViewGroup; -import android.widget.ArrayAdapter; -import android.widget.ListView; -import android.widget.PopupWindow; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.appcompat.app.AlertDialog; -import androidx.arch.core.util.Function; -import androidx.coordinatorlayout.widget.CoordinatorLayout; -import androidx.core.util.Pair; -import androidx.core.view.MenuProvider; -import androidx.lifecycle.Lifecycle; -import androidx.preference.PreferenceManager; -import androidx.recyclerview.widget.AsyncDifferConfig; -import androidx.recyclerview.widget.AsyncListDiffer; -import androidx.recyclerview.widget.DiffUtil; -import androidx.recyclerview.widget.DividerItemDecoration; -import androidx.recyclerview.widget.LinearLayoutManager; -import androidx.recyclerview.widget.ListUpdateCallback; -import androidx.recyclerview.widget.RecyclerView; -import androidx.recyclerview.widget.SimpleItemAnimator; -import androidx.swiperefreshlayout.widget.SwipeRefreshLayout; - -import com.google.android.material.appbar.AppBarLayout; -import com.google.android.material.floatingactionbutton.FloatingActionButton; -import com.keylesspalace.tusky.R; -import com.keylesspalace.tusky.adapter.NotificationsAdapter; -import com.keylesspalace.tusky.adapter.StatusBaseViewHolder; -import com.keylesspalace.tusky.appstore.BlockEvent; -import com.keylesspalace.tusky.appstore.BookmarkEvent; -import com.keylesspalace.tusky.appstore.EventHub; -import com.keylesspalace.tusky.appstore.FavoriteEvent; -import com.keylesspalace.tusky.appstore.PinEvent; -import com.keylesspalace.tusky.appstore.PreferenceChangedEvent; -import com.keylesspalace.tusky.appstore.ReblogEvent; -import com.keylesspalace.tusky.components.notifications.NotificationHelper; -import com.keylesspalace.tusky.databinding.FragmentTimelineNotificationsBinding; -import com.keylesspalace.tusky.db.AccountEntity; -import com.keylesspalace.tusky.db.AccountManager; -import com.keylesspalace.tusky.di.Injectable; -import com.keylesspalace.tusky.entity.Notification; -import com.keylesspalace.tusky.entity.Poll; -import com.keylesspalace.tusky.entity.Relationship; -import com.keylesspalace.tusky.entity.Status; -import com.keylesspalace.tusky.interfaces.AccountActionListener; -import com.keylesspalace.tusky.interfaces.ActionButtonActivity; -import com.keylesspalace.tusky.interfaces.ReselectableFragment; -import com.keylesspalace.tusky.interfaces.StatusActionListener; -import com.keylesspalace.tusky.settings.PrefKeys; -import com.keylesspalace.tusky.util.CardViewMode; -import com.keylesspalace.tusky.util.Either; -import com.keylesspalace.tusky.util.HttpHeaderLink; -import com.keylesspalace.tusky.util.LinkHelper; -import com.keylesspalace.tusky.util.ListStatusAccessibilityDelegate; -import com.keylesspalace.tusky.util.ListUtils; -import com.keylesspalace.tusky.util.NotificationTypeConverterKt; -import com.keylesspalace.tusky.util.PairedList; -import com.keylesspalace.tusky.util.StatusDisplayOptions; -import com.keylesspalace.tusky.util.ViewDataUtils; -import com.keylesspalace.tusky.view.EndlessOnScrollListener; -import com.keylesspalace.tusky.viewdata.AttachmentViewData; -import com.keylesspalace.tusky.viewdata.NotificationViewData; -import com.keylesspalace.tusky.viewdata.StatusViewData; - -import java.io.IOException; -import java.util.ArrayList; -import java.util.Collections; -import java.util.HashSet; -import java.util.Iterator; -import java.util.List; -import java.util.Locale; -import java.util.Objects; -import java.util.Set; -import java.util.concurrent.TimeUnit; - -import javax.inject.Inject; - -import at.connyduck.sparkbutton.helpers.Utils; -import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers; -import io.reactivex.rxjava3.core.Observable; -import io.reactivex.rxjava3.core.Single; -import io.reactivex.rxjava3.disposables.CompositeDisposable; -import io.reactivex.rxjava3.disposables.Disposable; -import kotlin.Unit; -import kotlin.collections.CollectionsKt; -import kotlin.jvm.functions.Function1; - -public class NotificationsFragment extends SFragment implements - SwipeRefreshLayout.OnRefreshListener, - StatusActionListener, - NotificationsAdapter.NotificationActionListener, - AccountActionListener, - Injectable, - MenuProvider, - ReselectableFragment { - private static final String TAG = "NotificationF"; // logging tag - - private static final int LOAD_AT_ONCE = 30; - private int maxPlaceholderId = 0; - - private final Set notificationFilter = new HashSet<>(); - - private final CompositeDisposable disposables = new CompositeDisposable(); - - private enum FetchEnd { - TOP, - BOTTOM, - MIDDLE - } - - /** - * Placeholder for the notificationsEnabled. Consider moving to the separate class to hide constructor - * and reuse in different places as needed. - */ - private static final class Placeholder { - final long id; - - public static Placeholder getInstance(long id) { - return new Placeholder(id); - } - - private Placeholder(long id) { - this.id = id; - } - } - - @Inject - AccountManager accountManager; - @Inject - EventHub eventHub; - - private FragmentTimelineNotificationsBinding binding; - - private LinearLayoutManager layoutManager; - private EndlessOnScrollListener scrollListener; - private NotificationsAdapter adapter; - private boolean hideFab; - private boolean topLoading; - private boolean bottomLoading; - private String bottomId; - private boolean alwaysShowSensitiveMedia; - private boolean alwaysOpenSpoiler; - private boolean showNotificationsFilter; - private boolean showingError; - - // Each element is either a Notification for loading data or a Placeholder - private final PairedList, NotificationViewData> notifications - = new PairedList<>(new Function<>() { - @Override - public NotificationViewData apply(Either input) { - if (input.isRight()) { - Notification notification = input.asRight() - .rewriteToStatusTypeIfNeeded(accountManager.getActiveAccount().getAccountId()); - - boolean sensitiveStatus = notification.getStatus() != null && notification.getStatus().getActionableStatus().getSensitive(); - - return ViewDataUtils.notificationToViewData( - notification, - alwaysShowSensitiveMedia || !sensitiveStatus, - alwaysOpenSpoiler, - true - ); - } else { - return new NotificationViewData.Placeholder(input.asLeft().id, false); - } - } - }); - - public static NotificationsFragment newInstance() { - NotificationsFragment fragment = new NotificationsFragment(); - Bundle arguments = new Bundle(); - fragment.setArguments(arguments); - return fragment; - } - - @Nullable - @Override - public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, - @Nullable Bundle savedInstanceState) { - requireActivity().addMenuProvider(this, getViewLifecycleOwner(), Lifecycle.State.RESUMED); - - binding = FragmentTimelineNotificationsBinding.inflate(inflater, container, false); - - @NonNull Context context = inflater.getContext(); // from inflater to silence warning - SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(requireContext()); - - boolean showNotificationsFilterSetting = preferences.getBoolean("showNotificationsFilter", true); - // Clear notifications on filter visibility change to force refresh - if (showNotificationsFilterSetting != showNotificationsFilter) - notifications.clear(); - showNotificationsFilter = showNotificationsFilterSetting; - - // Setup the SwipeRefreshLayout. - binding.swipeRefreshLayout.setOnRefreshListener(this); - binding.swipeRefreshLayout.setColorSchemeResources(R.color.tusky_blue); - - loadNotificationsFilter(); - - // Setup the RecyclerView. - binding.recyclerView.setHasFixedSize(true); - layoutManager = new LinearLayoutManager(context); - binding.recyclerView.setLayoutManager(layoutManager); - binding.recyclerView.setAccessibilityDelegateCompat( - new ListStatusAccessibilityDelegate(binding.recyclerView, this, (pos) -> { - NotificationViewData notification = notifications.getPairedItemOrNull(pos); - // We support replies only for now - if (notification instanceof NotificationViewData.Concrete) { - return ((NotificationViewData.Concrete) notification).getStatusViewData(); - } else { - return null; - } - })); - - binding.recyclerView.addItemDecoration(new DividerItemDecoration(context, DividerItemDecoration.VERTICAL)); - - StatusDisplayOptions statusDisplayOptions = new StatusDisplayOptions( - preferences.getBoolean("animateGifAvatars", false), - accountManager.getActiveAccount().getMediaPreviewEnabled(), - preferences.getBoolean("absoluteTimeView", false), - preferences.getBoolean("showBotOverlay", true), - preferences.getBoolean("useBlurhash", true), - CardViewMode.NONE, - preferences.getBoolean("confirmReblogs", true), - preferences.getBoolean("confirmFavourites", false), - preferences.getBoolean(PrefKeys.WELLBEING_HIDE_STATS_POSTS, false), - preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false) - ); - - adapter = new NotificationsAdapter(accountManager.getActiveAccount().getAccountId(), - dataSource, statusDisplayOptions, this, this, this); - alwaysShowSensitiveMedia = accountManager.getActiveAccount().getAlwaysShowSensitiveMedia(); - alwaysOpenSpoiler = accountManager.getActiveAccount().getAlwaysOpenSpoiler(); - binding.recyclerView.setAdapter(adapter); - - topLoading = false; - bottomLoading = false; - bottomId = null; - - updateAdapter(); - - binding.buttonClear.setOnClickListener(v -> confirmClearNotifications()); - binding.buttonFilter.setOnClickListener(v -> showFilterMenu()); - - if (notifications.isEmpty()) { - binding.swipeRefreshLayout.setEnabled(false); - sendFetchNotificationsRequest(null, null, FetchEnd.BOTTOM, -1); - } else { - binding.progressBar.setVisibility(View.GONE); - } - - ((SimpleItemAnimator) binding.recyclerView.getItemAnimator()).setSupportsChangeAnimations(false); - - updateFilterVisibility(); - - return binding.getRoot(); - } - - @Override - public void onDestroyView() { - super.onDestroyView(); - binding = null; - } - - @Override - public void onCreateMenu(@NonNull Menu menu, @NonNull MenuInflater menuInflater) { - menuInflater.inflate(R.menu.fragment_notifications, menu); - } - - @Override - public boolean onMenuItemSelected(@NonNull MenuItem menuItem) { - if (menuItem.getItemId() == R.id.action_refresh) { - binding.swipeRefreshLayout.setRefreshing(true); - onRefresh(); - return true; - } - - return false; - } - - private void updateFilterVisibility() { - CoordinatorLayout.LayoutParams params = - (CoordinatorLayout.LayoutParams) binding.swipeRefreshLayout.getLayoutParams(); - if (showNotificationsFilter && !showingError) { - binding.appBarOptions.setExpanded(true, false); - binding.appBarOptions.setVisibility(View.VISIBLE); - // Set content behaviour to hide filter on scroll - params.setBehavior(new AppBarLayout.ScrollingViewBehavior()); - } else { - binding.appBarOptions.setExpanded(false, false); - binding.appBarOptions.setVisibility(View.GONE); - // Clear behaviour to hide app bar - params.setBehavior(null); - } - } - - private void confirmClearNotifications() { - new AlertDialog.Builder(requireContext()) - .setMessage(R.string.notification_clear_text) - .setPositiveButton(android.R.string.ok, (DialogInterface dia, int which) -> clearNotifications()) - .setNegativeButton(android.R.string.cancel, null) - .show(); - } - - @Override - public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { - super.onViewCreated(view, savedInstanceState); - Activity activity = getActivity(); - if (activity == null) throw new AssertionError("Activity is null"); - - // This is delayed until onActivityCreated solely because MainActivity.composeButton - // isn't guaranteed to be set until then. - // Use a modified scroll listener that both loads more notificationsEnabled as it - // goes, and hides the compose button on down-scroll. - SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(activity); - hideFab = preferences.getBoolean("fabHide", false); - scrollListener = new EndlessOnScrollListener(layoutManager) { - @Override - public void onScrolled(@NonNull RecyclerView view, int dx, int dy) { - super.onScrolled(view, dx, dy); - - ActionButtonActivity activity = (ActionButtonActivity) getActivity(); - FloatingActionButton composeButton = activity.getActionButton(); - - if (composeButton != null) { - if (hideFab) { - if (dy > 0 && composeButton.isShown()) { - composeButton.hide(); // Hides the button if we're scrolling down - } else if (dy < 0 && !composeButton.isShown()) { - composeButton.show(); // Shows it if we are scrolling up - } - } else if (!composeButton.isShown()) { - composeButton.show(); - } - } - } - - @Override - public void onLoadMore(int totalItemsCount, @NonNull RecyclerView view) { - NotificationsFragment.this.onLoadMore(); - } - }; - - binding.recyclerView.addOnScrollListener(scrollListener); - - eventHub.getEvents() - .observeOn(AndroidSchedulers.mainThread()) - .to(autoDisposable(from(this, Lifecycle.Event.ON_DESTROY))) - .subscribe(event -> { - if (event instanceof FavoriteEvent) { - setFavouriteForStatus(((FavoriteEvent) event).getStatusId(), ((FavoriteEvent) event).getFavourite()); - } else if (event instanceof BookmarkEvent) { - setBookmarkForStatus(((BookmarkEvent) event).getStatusId(), ((BookmarkEvent) event).getBookmark()); - } else if (event instanceof ReblogEvent) { - setReblogForStatus(((ReblogEvent) event).getStatusId(), ((ReblogEvent) event).getReblog()); - } else if (event instanceof PinEvent) { - setPinForStatus(((PinEvent) event).getStatusId(), ((PinEvent) event).getPinned()); - } else if (event instanceof BlockEvent) { - removeAllByAccountId(((BlockEvent) event).getAccountId()); - } else if (event instanceof PreferenceChangedEvent) { - onPreferenceChanged(((PreferenceChangedEvent) event).getPreferenceKey()); - } - }); - } - - @Override - public void onRefresh() { - binding.statusView.setVisibility(View.GONE); - this.showingError = false; - Either first = CollectionsKt.firstOrNull(this.notifications); - String topId; - if (first != null && first.isRight()) { - topId = first.asRight().getId(); - } else { - topId = null; - } - sendFetchNotificationsRequest(null, topId, FetchEnd.TOP, -1); - } - - @Override - public void onReply(int position) { - super.reply(notifications.get(position).asRight().getStatus()); - } - - @Override - public void onReblog(final boolean reblog, final int position) { - final Notification notification = notifications.get(position).asRight(); - final Status status = notification.getStatus(); - Objects.requireNonNull(status, "Reblog on notification without status"); - timelineCases.reblog(status.getId(), reblog) - .observeOn(AndroidSchedulers.mainThread()) - .to(autoDisposable(from(this))) - .subscribe( - (newStatus) -> setReblogForStatus(status.getId(), reblog), - (t) -> Log.d(getClass().getSimpleName(), - "Failed to reblog status: " + status.getId(), t) - ); - } - - private void setReblogForStatus(String statusId, boolean reblog) { - updateStatus(statusId, (s) -> s.copyWithReblogged(reblog)); - } - - @Override - public void onFavourite(final boolean favourite, final int position) { - final Notification notification = notifications.get(position).asRight(); - final Status status = notification.getStatus(); - - timelineCases.favourite(status.getId(), favourite) - .observeOn(AndroidSchedulers.mainThread()) - .to(autoDisposable(from(this))) - .subscribe( - (newStatus) -> setFavouriteForStatus(status.getId(), favourite), - (t) -> Log.d(getClass().getSimpleName(), - "Failed to favourite status: " + status.getId(), t) - ); - } - - private void setFavouriteForStatus(String statusId, boolean favourite) { - updateStatus(statusId, (s) -> s.copyWithFavourited(favourite)); - } - - @Override - public void onBookmark(final boolean bookmark, final int position) { - final Notification notification = notifications.get(position).asRight(); - final Status status = notification.getStatus(); - - timelineCases.bookmark(status.getActionableId(), bookmark) - .observeOn(AndroidSchedulers.mainThread()) - .to(autoDisposable(from(this))) - .subscribe( - (newStatus) -> setBookmarkForStatus(status.getId(), bookmark), - (t) -> Log.d(getClass().getSimpleName(), - "Failed to bookmark status: " + status.getId(), t) - ); - } - - private void setBookmarkForStatus(String statusId, boolean bookmark) { - updateStatus(statusId, (s) -> s.copyWithBookmarked(bookmark)); - } - - public void onVoteInPoll(int position, @NonNull List choices) { - final Notification notification = notifications.get(position).asRight(); - final Status status = notification.getStatus().getActionableStatus(); - timelineCases.voteInPoll(status.getId(), status.getPoll().getId(), choices) - .observeOn(AndroidSchedulers.mainThread()) - .to(autoDisposable(from(this))) - .subscribe( - (newPoll) -> setVoteForPoll(status, newPoll), - (t) -> Log.d(TAG, - "Failed to vote in poll: " + status.getId(), t) - ); - } - - private void setVoteForPoll(Status status, Poll poll) { - updateStatus(status.getId(), (s) -> s.copyWithPoll(poll)); - } - - @Override - public void onMore(@NonNull View view, int position) { - Notification notification = notifications.get(position).asRight(); - super.more(notification.getStatus(), view, position); - } - - @Override - public void onViewMedia(int position, int attachmentIndex, @Nullable View view) { - Notification notification = notifications.get(position).asRightOrNull(); - if (notification == null || notification.getStatus() == null) return; - Status status = notification.getStatus(); - super.viewMedia(attachmentIndex, AttachmentViewData.list(status), view); - } - - @Override - public void onViewThread(int position) { - Notification notification = notifications.get(position).asRight(); - Status status = notification.getStatus(); - if (status == null) return; - super.viewThread(status.getActionableId(), status.getActionableStatus().getUrl()); - } - - @Override - public void onOpenReblog(int position) { - Notification notification = notifications.get(position).asRight(); - onViewAccount(notification.getAccount().getId()); - } - - @Override - public void onExpandedChange(boolean expanded, int position) { - updateViewDataAt(position, (vd) -> vd.copyWithExpanded(expanded)); - } - - @Override - public void onContentHiddenChange(boolean isShowing, int position) { - updateViewDataAt(position, (vd) -> vd.copyWithShowingContent(isShowing)); - } - - private void setPinForStatus(String statusId, boolean pinned) { - updateStatus(statusId, status -> status.copyWithPinned(pinned)); - } - - @Override - public void onLoadMore(int position) { - // Check bounds before accessing list, - if (notifications.size() >= position && position > 0) { - Notification previous = notifications.get(position - 1).asRightOrNull(); - Notification next = notifications.get(position + 1).asRightOrNull(); - if (previous == null || next == null) { - Log.e(TAG, "Failed to load more, invalid placeholder position: " + position); - return; - } - sendFetchNotificationsRequest(previous.getId(), next.getId(), FetchEnd.MIDDLE, position); - Placeholder placeholder = notifications.get(position).asLeft(); - NotificationViewData notificationViewData = - new NotificationViewData.Placeholder(placeholder.id, true); - notifications.setPairedItem(position, notificationViewData); - updateAdapter(); - } else { - Log.d(TAG, "error loading more"); - } - } - - @Override - public void onContentCollapsedChange(boolean isCollapsed, int position) { - updateViewDataAt(position, (vd) -> vd.copyWithCollapsed(isCollapsed)); - } - - private void updateStatus(String statusId, Function mapper) { - int index = CollectionsKt.indexOfFirst(this.notifications, (s) -> s.isRight() && - s.asRight().getStatus() != null && - s.asRight().getStatus().getId().equals(statusId)); - if (index == -1) return; - - // We have quite some graph here: - // - // Notification --------> Status - // ^ - // | - // StatusViewData - // ^ - // | - // NotificationViewData -----+ - // - // So if we have "new" status we need to update all references to be sure that data is - // up-to-date: - // 1. update status - // 2. update notification - // 3. update statusViewData - // 4. update notificationViewData - - Status oldStatus = notifications.get(index).asRight().getStatus(); - NotificationViewData.Concrete oldViewData = - (NotificationViewData.Concrete) this.notifications.getPairedItem(index); - Status newStatus = mapper.apply(oldStatus); - Notification newNotification = this.notifications.get(index).asRight() - .copyWithStatus(newStatus); - StatusViewData.Concrete newStatusViewData = - Objects.requireNonNull(oldViewData.getStatusViewData()).copyWithStatus(newStatus); - NotificationViewData.Concrete newViewData = oldViewData.copyWithStatus(newStatusViewData); - - notifications.set(index, new Either.Right<>(newNotification)); - notifications.setPairedItem(index, newViewData); - - updateAdapter(); - } - - private void updateViewDataAt(int position, - Function mapper) { - if (position < 0 || position >= notifications.size()) { - String message = String.format( - Locale.getDefault(), - "Tried to access out of bounds status position: %d of %d", - position, - notifications.size() - 1 - ); - Log.e(TAG, message); - return; - } - NotificationViewData someViewData = this.notifications.getPairedItem(position); - if (!(someViewData instanceof NotificationViewData.Concrete)) { - return; - } - NotificationViewData.Concrete oldViewData = (NotificationViewData.Concrete) someViewData; - StatusViewData.Concrete oldStatusViewData = oldViewData.getStatusViewData(); - if (oldStatusViewData == null) return; - - NotificationViewData.Concrete newViewData = - oldViewData.copyWithStatus(mapper.apply(oldStatusViewData)); - notifications.setPairedItem(position, newViewData); - - updateAdapter(); - } - - @Override - public void onNotificationContentCollapsedChange(boolean isCollapsed, int position) { - onContentCollapsedChange(isCollapsed, position); - } - - private void clearNotifications() { - // Cancel all ongoing requests - binding.swipeRefreshLayout.setRefreshing(false); - resetNotificationsLoad(); - - // Show friend elephant - binding.statusView.setVisibility(View.VISIBLE); - binding.statusView.setup(R.drawable.elephant_friend_empty, R.string.message_empty, null); - updateFilterVisibility(); - - // Update adapter - updateAdapter(); - - // Execute clear notifications request - mastodonApi.clearNotifications() - .observeOn(AndroidSchedulers.mainThread()) - .to(autoDisposable(from(this, Lifecycle.Event.ON_DESTROY))) - .subscribe( - response -> { - // Nothing to do - }, - throwable -> { - // Reload notifications on failure - fullyRefreshWithProgressBar(true); - }); - } - - private void resetNotificationsLoad() { - disposables.clear(); - bottomLoading = false; - topLoading = false; - - // Disable load more - bottomId = null; - - // Clear exists notifications - notifications.clear(); - } - - - private void showFilterMenu() { - List notificationsList = Notification.Type.Companion.getAsList(); - List list = new ArrayList<>(); - for (Notification.Type type : notificationsList) { - list.add(getNotificationText(type)); - } - - ArrayAdapter adapter = new ArrayAdapter<>(getContext(), android.R.layout.simple_list_item_multiple_choice, list); - PopupWindow window = new PopupWindow(getContext()); - View view = LayoutInflater.from(getContext()).inflate(R.layout.notifications_filter, (ViewGroup) getView(), false); - final ListView listView = view.findViewById(R.id.listView); - view.findViewById(R.id.buttonApply) - .setOnClickListener(v -> { - SparseBooleanArray checkedItems = listView.getCheckedItemPositions(); - Set excludes = new HashSet<>(); - for (int i = 0; i < notificationsList.size(); i++) { - if (!checkedItems.get(i, false)) - excludes.add(notificationsList.get(i)); - } - window.dismiss(); - applyFilterChanges(excludes); - - }); - - listView.setAdapter(adapter); - listView.setChoiceMode(ListView.CHOICE_MODE_MULTIPLE); - for (int i = 0; i < notificationsList.size(); i++) { - if (!notificationFilter.contains(notificationsList.get(i))) - listView.setItemChecked(i, true); - } - window.setContentView(view); - window.setFocusable(true); - window.setWidth(ViewGroup.LayoutParams.WRAP_CONTENT); - window.setHeight(ViewGroup.LayoutParams.WRAP_CONTENT); - window.showAsDropDown(binding.buttonFilter); - - } - - private String getNotificationText(Notification.Type type) { - switch (type) { - case MENTION: - return getString(R.string.notification_mention_name); - case FAVOURITE: - return getString(R.string.notification_favourite_name); - case REBLOG: - return getString(R.string.notification_boost_name); - case FOLLOW: - return getString(R.string.notification_follow_name); - case FOLLOW_REQUEST: - return getString(R.string.notification_follow_request_name); - case POLL: - return getString(R.string.notification_poll_name); - case STATUS: - return getString(R.string.notification_subscription_name); - case SIGN_UP: - return getString(R.string.notification_sign_up_name); - case UPDATE: - return getString(R.string.notification_update_name); - case REPORT: - return getString(R.string.notification_report_name); - default: - return "Unknown"; - } - } - - private void applyFilterChanges(Set newSet) { - List notifications = Notification.Type.Companion.getAsList(); - boolean isChanged = false; - for (Notification.Type type : notifications) { - if (notificationFilter.contains(type) && !newSet.contains(type)) { - notificationFilter.remove(type); - isChanged = true; - } else if (!notificationFilter.contains(type) && newSet.contains(type)) { - notificationFilter.add(type); - isChanged = true; - } - } - if (isChanged) { - saveNotificationsFilter(); - fullyRefreshWithProgressBar(true); - } - - } - - private void loadNotificationsFilter() { - AccountEntity account = accountManager.getActiveAccount(); - if (account != null) { - notificationFilter.clear(); - notificationFilter.addAll(NotificationTypeConverterKt.deserialize( - account.getNotificationsFilter())); - } - } - - private void saveNotificationsFilter() { - AccountEntity account = accountManager.getActiveAccount(); - if (account != null) { - account.setNotificationsFilter(NotificationTypeConverterKt.serialize(notificationFilter)); - accountManager.saveAccount(account); - } - } - - @Override - public void onViewTag(@NonNull String tag) { - super.viewTag(tag); - } - - @Override - public void onViewAccount(@NonNull String id) { - super.viewAccount(id); - } - - @Override - public void onMute(boolean mute, String id, int position, boolean notifications) { - // No muting from notifications yet - } - - @Override - public void onBlock(boolean block, String id, int position) { - // No blocking from notifications yet - } - - @Override - public void onRespondToFollowRequest(boolean accept, String id, int position) { - Single request = accept ? - mastodonApi.authorizeFollowRequest(id) : - mastodonApi.rejectFollowRequest(id); - request.observeOn(AndroidSchedulers.mainThread()) - .to(autoDisposable(from(this, Lifecycle.Event.ON_DESTROY))) - .subscribe( - (relationship) -> fullyRefreshWithProgressBar(true), - (error) -> Log.e(TAG, String.format("Failed to %s account id %s", accept ? "accept" : "reject", id)) - ); - } - - @Override - public void onViewStatusForNotificationId(String notificationId) { - for (Either either : notifications) { - Notification notification = either.asRightOrNull(); - if (notification != null && notification.getId().equals(notificationId)) { - Status status = notification.getStatus(); - if (status != null) { - super.viewThread(status.getActionableId(), status.getActionableStatus().getUrl()); - return; - } - } - } - Log.w(TAG, "Didn't find a notification for ID: " + notificationId); - } - - @Override - public void onViewReport(String reportId) { - LinkHelper.openLink(requireContext(), String.format("https://%s/admin/reports/%s", accountManager.getActiveAccount().getDomain(), reportId)); - } - - private void onPreferenceChanged(String key) { - switch (key) { - case "fabHide": { - hideFab = PreferenceManager.getDefaultSharedPreferences(requireContext()).getBoolean("fabHide", false); - break; - } - case "mediaPreviewEnabled": { - boolean enabled = accountManager.getActiveAccount().getMediaPreviewEnabled(); - if (enabled != adapter.isMediaPreviewEnabled()) { - adapter.setMediaPreviewEnabled(enabled); - fullyRefresh(); - } - break; - } - case "showNotificationsFilter": { - if (isAdded()) { - showNotificationsFilter = PreferenceManager.getDefaultSharedPreferences(requireContext()).getBoolean("showNotificationsFilter", true); - updateFilterVisibility(); - fullyRefreshWithProgressBar(true); - } - break; - } - } - } - - @Override - public void removeItem(int position) { - notifications.remove(position); - updateAdapter(); - } - - private void removeAllByAccountId(String accountId) { - // Using iterator to safely remove items while iterating - Iterator> iterator = notifications.iterator(); - while (iterator.hasNext()) { - Either notification = iterator.next(); - Notification maybeNotification = notification.asRightOrNull(); - if (maybeNotification != null && maybeNotification.getAccount().getId().equals(accountId)) { - iterator.remove(); - } - } - updateAdapter(); - } - - private void onLoadMore() { - if (bottomId == null) { - // Already loaded everything - return; - } - - // Check for out-of-bounds when loading - // This is required to allow full-timeline reloads of collapsible statuses when the settings - // change. - if (notifications.size() > 0) { - Either last = notifications.get(notifications.size() - 1); - if (last.isRight()) { - final Placeholder placeholder = newPlaceholder(); - notifications.add(new Either.Left<>(placeholder)); - NotificationViewData viewData = - new NotificationViewData.Placeholder(placeholder.id, true); - notifications.setPairedItem(notifications.size() - 1, viewData); - updateAdapter(); - } - } - - sendFetchNotificationsRequest(bottomId, null, FetchEnd.BOTTOM, -1); - } - - private Placeholder newPlaceholder() { - Placeholder placeholder = Placeholder.getInstance(maxPlaceholderId); - maxPlaceholderId--; - return placeholder; - } - - private void jumpToTop() { - if (isAdded()) { - binding.appBarOptions.setExpanded(true, false); - layoutManager.scrollToPosition(0); - scrollListener.reset(); - } - } - - private void sendFetchNotificationsRequest(String fromId, String uptoId, - final FetchEnd fetchEnd, final int pos) { - // If there is a fetch already ongoing, record however many fetches are requested and - // fulfill them after it's complete. - if (fetchEnd == FetchEnd.TOP && topLoading) { - return; - } - if (fetchEnd == FetchEnd.BOTTOM && bottomLoading) { - return; - } - if (fetchEnd == FetchEnd.TOP) { - topLoading = true; - } - if (fetchEnd == FetchEnd.BOTTOM) { - bottomLoading = true; - } - - Disposable notificationCall = mastodonApi.notifications(fromId, uptoId, LOAD_AT_ONCE, showNotificationsFilter ? notificationFilter : null) - .observeOn(AndroidSchedulers.mainThread()) - .to(autoDisposable(from(this, Lifecycle.Event.ON_DESTROY))) - .subscribe( - response -> { - if (response.isSuccessful()) { - String linkHeader = response.headers().get("Link"); - onFetchNotificationsSuccess(response.body(), linkHeader, fetchEnd, pos); - } else { - onFetchNotificationsFailure(new Exception(response.message()), fetchEnd, pos); - } - }, - throwable -> onFetchNotificationsFailure(throwable, fetchEnd, pos)); - disposables.add(notificationCall); - } - - private void onFetchNotificationsSuccess(List notifications, String linkHeader, - FetchEnd fetchEnd, int pos) { - List links = HttpHeaderLink.Companion.parse(linkHeader); - HttpHeaderLink next = HttpHeaderLink.Companion.findByRelationType(links, "next"); - String fromId = null; - if (next != null) { - fromId = next.getUri().getQueryParameter("max_id"); - } - - switch (fetchEnd) { - case TOP: { - update(notifications, this.notifications.isEmpty() ? fromId : null); - break; - } - case MIDDLE: { - replacePlaceholderWithNotifications(notifications, pos); - break; - } - case BOTTOM: { - - if (!this.notifications.isEmpty() - && !this.notifications.get(this.notifications.size() - 1).isRight()) { - this.notifications.remove(this.notifications.size() - 1); - updateAdapter(); - } - - if (adapter.getItemCount() > 1) { - addItems(notifications, fromId); - } else { - update(notifications, fromId); - } - - break; - } - } - - saveNewestNotificationId(notifications); - - if (fetchEnd == FetchEnd.TOP) { - topLoading = false; - } - if (fetchEnd == FetchEnd.BOTTOM) { - bottomLoading = false; - } - - if (notifications.size() == 0 && adapter.getItemCount() == 0) { - binding.statusView.setVisibility(View.VISIBLE); - binding.statusView.setup(R.drawable.elephant_friend_empty, R.string.message_empty, null); - } - - updateFilterVisibility(); - binding.swipeRefreshLayout.setEnabled(true); - binding.swipeRefreshLayout.setRefreshing(false); - binding.progressBar.setVisibility(View.GONE); - } - - private void onFetchNotificationsFailure(Throwable throwable, FetchEnd fetchEnd, int position) { - binding.swipeRefreshLayout.setRefreshing(false); - if (fetchEnd == FetchEnd.MIDDLE && !notifications.get(position).isRight()) { - Placeholder placeholder = notifications.get(position).asLeft(); - NotificationViewData placeholderVD = - new NotificationViewData.Placeholder(placeholder.id, false); - notifications.setPairedItem(position, placeholderVD); - updateAdapter(); - } else if (this.notifications.isEmpty()) { - binding.statusView.setVisibility(View.VISIBLE); - binding.swipeRefreshLayout.setEnabled(false); - this.showingError = true; - if (throwable instanceof IOException) { - binding.statusView.setup(R.drawable.elephant_offline, R.string.error_network, __ -> { - binding.progressBar.setVisibility(View.VISIBLE); - this.onRefresh(); - return Unit.INSTANCE; - }); - } else { - binding.statusView.setup(R.drawable.elephant_error, R.string.error_generic, __ -> { - binding.progressBar.setVisibility(View.VISIBLE); - this.onRefresh(); - return Unit.INSTANCE; - }); - } - updateFilterVisibility(); - } - Log.e(TAG, "Fetch failure: " + throwable.getMessage()); - - if (fetchEnd == FetchEnd.TOP) { - topLoading = false; - } - if (fetchEnd == FetchEnd.BOTTOM) { - bottomLoading = false; - } - - binding.progressBar.setVisibility(View.GONE); - } - - private void saveNewestNotificationId(List notifications) { - - AccountEntity account = accountManager.getActiveAccount(); - if (account != null) { - String lastNotificationId = account.getLastNotificationId(); - - for (Notification noti : notifications) { - if (isLessThan(lastNotificationId, noti.getId())) { - lastNotificationId = noti.getId(); - } - } - - if (!account.getLastNotificationId().equals(lastNotificationId)) { - Log.d(TAG, "saving newest noti id: " + lastNotificationId); - account.setLastNotificationId(lastNotificationId); - accountManager.saveAccount(account); - } - } - } - - private void update(@Nullable List newNotifications, @Nullable String fromId) { - if (ListUtils.isEmpty(newNotifications)) { - updateAdapter(); - return; - } - if (fromId != null) { - bottomId = fromId; - } - List> liftedNew = - liftNotificationList(newNotifications); - if (notifications.isEmpty()) { - notifications.addAll(liftedNew); - } else { - int index = notifications.indexOf(liftedNew.get(newNotifications.size() - 1)); - if (index > 0) { - notifications.subList(0, index).clear(); - } - - int newIndex = liftedNew.indexOf(notifications.get(0)); - if (newIndex == -1) { - if (index == -1 && liftedNew.size() >= LOAD_AT_ONCE) { - liftedNew.add(new Either.Left<>(newPlaceholder())); - } - notifications.addAll(0, liftedNew); - } else { - notifications.addAll(0, liftedNew.subList(0, newIndex)); - } - } - updateAdapter(); - } - - private void addItems(List newNotifications, @Nullable String fromId) { - bottomId = fromId; - if (ListUtils.isEmpty(newNotifications)) { - return; - } - int end = notifications.size(); - List> liftedNew = liftNotificationList(newNotifications); - Either last = notifications.get(end - 1); - if (last != null && !liftedNew.contains(last)) { - notifications.addAll(liftedNew); - updateAdapter(); - } - } - - private void replacePlaceholderWithNotifications(List newNotifications, int pos) { - // Remove placeholder - notifications.remove(pos); - - if (ListUtils.isEmpty(newNotifications)) { - updateAdapter(); - return; - } - - List> liftedNew = liftNotificationList(newNotifications); - - // If we fetched less posts than in the limit, it means that the hole is not filled - // If we fetched at least as much it means that there are more posts to load and we should - // insert new placeholder - if (newNotifications.size() >= LOAD_AT_ONCE) { - liftedNew.add(new Either.Left<>(newPlaceholder())); - } - - notifications.addAll(pos, liftedNew); - updateAdapter(); - } - - private final Function1> notificationLifter = - Either.Right::new; - - private List> liftNotificationList(List list) { - return CollectionsKt.map(list, notificationLifter); - } - - private void fullyRefreshWithProgressBar(boolean isShow) { - resetNotificationsLoad(); - if (isShow) { - binding.progressBar.setVisibility(View.VISIBLE); - binding.statusView.setVisibility(View.GONE); - } - updateAdapter(); - sendFetchNotificationsRequest(null, null, FetchEnd.TOP, -1); - } - - private void fullyRefresh() { - fullyRefreshWithProgressBar(false); - } - - @Nullable - private Pair findReplyPosition(@NonNull String statusId) { - for (int i = 0; i < notifications.size(); i++) { - Notification notification = notifications.get(i).asRightOrNull(); - if (notification != null - && notification.getStatus() != null - && notification.getType() == Notification.Type.MENTION - && (statusId.equals(notification.getStatus().getId()) - || (notification.getStatus().getReblog() != null - && statusId.equals(notification.getStatus().getReblog().getId())))) { - return new Pair<>(i, notification); - } - } - return null; - } - - private void updateAdapter() { - differ.submitList(notifications.getPairedCopy()); - } - - private final ListUpdateCallback listUpdateCallback = new ListUpdateCallback() { - @Override - public void onInserted(int position, int count) { - if (isAdded()) { - adapter.notifyItemRangeInserted(position, count); - Context context = getContext(); - // scroll up when new items at the top are loaded while being at the start - // https://github.com/tuskyapp/Tusky/pull/1905#issuecomment-677819724 - if (position == 0 && context != null && adapter.getItemCount() != count) { - binding.recyclerView.scrollBy(0, Utils.dpToPx(context, -30)); - } - } - } - - @Override - public void onRemoved(int position, int count) { - adapter.notifyItemRangeRemoved(position, count); - } - - @Override - public void onMoved(int fromPosition, int toPosition) { - adapter.notifyItemMoved(fromPosition, toPosition); - } - - @Override - public void onChanged(int position, int count, Object payload) { - adapter.notifyItemRangeChanged(position, count, payload); - } - }; - - private final AsyncListDiffer - differ = new AsyncListDiffer<>(listUpdateCallback, - new AsyncDifferConfig.Builder<>(diffCallback).build()); - - private final NotificationsAdapter.AdapterDataSource dataSource = - new NotificationsAdapter.AdapterDataSource<>() { - @Override - public int getItemCount() { - return differ.getCurrentList().size(); - } - - @Override - public NotificationViewData getItemAt(int pos) { - return differ.getCurrentList().get(pos); - } - }; - - private static final DiffUtil.ItemCallback diffCallback - = new DiffUtil.ItemCallback<>() { - - @Override - public boolean areItemsTheSame(NotificationViewData oldItem, NotificationViewData newItem) { - return oldItem.getViewDataId() == newItem.getViewDataId(); - } - - @Override - public boolean areContentsTheSame(@NonNull NotificationViewData oldItem, @NonNull NotificationViewData newItem) { - return false; - } - - @Nullable - @Override - public Object getChangePayload(@NonNull NotificationViewData oldItem, @NonNull NotificationViewData newItem) { - if (oldItem.deepEquals(newItem)) { - // If items are equal - update timestamp only - return Collections.singletonList(StatusBaseViewHolder.Key.KEY_CREATED); - } else - // If items are different - update a whole view holder - return null; - } - }; - - @Override - public void onResume() { - super.onResume(); - - NotificationHelper.clearNotificationsForActiveAccount(requireContext(), accountManager); - - String rawAccountNotificationFilter = accountManager.getActiveAccount().getNotificationsFilter(); - Set accountNotificationFilter = NotificationTypeConverterKt.deserialize(rawAccountNotificationFilter); - if (!notificationFilter.equals(accountNotificationFilter)) { - loadNotificationsFilter(); - fullyRefreshWithProgressBar(true); - } - startUpdateTimestamp(); - } - - /** - * Start to update adapter every minute to refresh timestamp - * If setting absoluteTimeView is false - * Auto dispose observable on pause - */ - private void startUpdateTimestamp() { - SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(requireContext()); - boolean useAbsoluteTime = preferences.getBoolean("absoluteTimeView", false); - if (!useAbsoluteTime) { - Observable.interval(0, 1, TimeUnit.MINUTES) - .observeOn(AndroidSchedulers.mainThread()) - .to(autoDisposable(from(this, Lifecycle.Event.ON_PAUSE))) - .subscribe( - interval -> updateAdapter() - ); - } - - } - - @Override - public void onReselect() { - jumpToTop(); - } -} diff --git a/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.kt b/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.kt index 6420216a0..a94c8a350 100644 --- a/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.kt +++ b/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.kt @@ -123,12 +123,22 @@ interface MastodonApi { ): Response> @GET("api/v1/notifications") - fun notifications( - @Query("max_id") maxId: String?, - @Query("since_id") sinceId: String?, - @Query("limit") limit: Int?, - @Query("exclude_types[]") excludes: Set? - ): Single>> + suspend fun notifications( + /** Return results older than this ID */ + @Query("max_id") maxId: String? = null, + /** Return results immediately newer than this ID */ + @Query("min_id") minId: String? = null, + /** Maximum number of results to return. Defaults to 15, max is 30 */ + @Query("limit") limit: Int? = null, + /** Types to excludes from the results */ + @Query("exclude_types[]") excludes: Set? = null + ): Response> + + /** Fetch a single notification */ + @GET("api/v1/notifications/{id}") + suspend fun notification( + @Path("id") id: String + ): Response @GET("api/v1/markers") fun markersWithAuth( @@ -145,7 +155,7 @@ interface MastodonApi { ): Single> @POST("api/v1/notifications/clear") - fun clearNotifications(): Single + suspend fun clearNotifications(): Response @FormUrlEncoded @PUT("api/v1/media/{mediaId}") diff --git a/app/src/main/java/com/keylesspalace/tusky/usecase/TimelineCases.kt b/app/src/main/java/com/keylesspalace/tusky/usecase/TimelineCases.kt index 45842f8e3..6f102bfcd 100644 --- a/app/src/main/java/com/keylesspalace/tusky/usecase/TimelineCases.kt +++ b/app/src/main/java/com/keylesspalace/tusky/usecase/TimelineCases.kt @@ -31,6 +31,7 @@ import com.keylesspalace.tusky.appstore.ReblogEvent import com.keylesspalace.tusky.appstore.StatusDeletedEvent import com.keylesspalace.tusky.entity.DeletedStatus import com.keylesspalace.tusky.entity.Poll +import com.keylesspalace.tusky.entity.Relationship import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.util.getServerErrorMessage @@ -143,6 +144,14 @@ class TimelineCases @Inject constructor( } } + fun acceptFollowRequest(accountId: String): Single { + return mastodonApi.authorizeFollowRequest(accountId) + } + + fun rejectFollowRequest(accountId: String): Single { + return mastodonApi.rejectFollowRequest(accountId) + } + private fun convertError(e: Throwable): Single { return Single.error(TimelineError(e.getServerErrorMessage())) } diff --git a/app/src/main/java/com/keylesspalace/tusky/util/StatusDisplayOptions.kt b/app/src/main/java/com/keylesspalace/tusky/util/StatusDisplayOptions.kt index cb7821073..7767accd0 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/StatusDisplayOptions.kt +++ b/app/src/main/java/com/keylesspalace/tusky/util/StatusDisplayOptions.kt @@ -1,5 +1,26 @@ +/* + * Copyright 2023 Tusky Contributors + * + * This file is a part of Tusky. + * + * This program is free software; you can redistribute it and/or modify it under the terms of the + * GNU General Public License as published by the Free Software Foundation; either version 3 of the + * License, or (at your option) any later version. + * + * Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even + * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General + * Public License for more details. + * + * You should have received a copy of the GNU General Public License along with Tusky; if not, + * see . + */ + package com.keylesspalace.tusky.util +import android.content.SharedPreferences +import com.keylesspalace.tusky.db.AccountEntity +import com.keylesspalace.tusky.settings.PrefKeys + data class StatusDisplayOptions( @get:JvmName("animateAvatars") val animateAvatars: Boolean, @@ -20,5 +41,86 @@ data class StatusDisplayOptions( @get:JvmName("hideStats") val hideStats: Boolean, @get:JvmName("animateEmojis") - val animateEmojis: Boolean -) + val animateEmojis: Boolean, + @get:JvmName("showSensitiveMedia") + val showSensitiveMedia: Boolean, + @get:JvmName("openSpoiler") + val openSpoiler: Boolean +) { + + /** + * @return a new StatusDisplayOptions adapted to whichever preference changed. + */ + fun make( + preferences: SharedPreferences, + key: String, + account: AccountEntity + ) = when (key) { + PrefKeys.ANIMATE_GIF_AVATARS -> copy( + animateAvatars = preferences.getBoolean(key, false) + ) + PrefKeys.MEDIA_PREVIEW_ENABLED -> copy( + mediaPreviewEnabled = account.mediaPreviewEnabled + ) + PrefKeys.ABSOLUTE_TIME_VIEW -> copy( + useAbsoluteTime = preferences.getBoolean(key, false) + ) + PrefKeys.SHOW_BOT_OVERLAY -> copy( + showBotOverlay = preferences.getBoolean(key, true) + ) + PrefKeys.USE_BLURHASH -> copy( + useBlurhash = preferences.getBoolean(key, true) + ) + PrefKeys.CONFIRM_FAVOURITES -> copy( + confirmFavourites = preferences.getBoolean(key, false) + ) + PrefKeys.CONFIRM_REBLOGS -> copy( + confirmReblogs = preferences.getBoolean(key, true) + ) + PrefKeys.WELLBEING_HIDE_STATS_POSTS -> copy( + hideStats = preferences.getBoolean(key, false) + ) + PrefKeys.ANIMATE_CUSTOM_EMOJIS -> copy( + animateEmojis = preferences.getBoolean(key, false) + ) + PrefKeys.ALWAYS_SHOW_SENSITIVE_MEDIA -> copy( + showSensitiveMedia = account.alwaysShowSensitiveMedia + ) + PrefKeys.ALWAYS_OPEN_SPOILER -> copy( + openSpoiler = account.alwaysOpenSpoiler + ) + else -> { this } + } + + companion object { + /** Preference keys that, if changed, affect StatusDisplayOptions */ + val prefKeys = setOf( + PrefKeys.ABSOLUTE_TIME_VIEW, + PrefKeys.ALWAYS_SHOW_SENSITIVE_MEDIA, + PrefKeys.ALWAYS_OPEN_SPOILER, + PrefKeys.ANIMATE_CUSTOM_EMOJIS, + PrefKeys.ANIMATE_GIF_AVATARS, + PrefKeys.CONFIRM_FAVOURITES, + PrefKeys.CONFIRM_REBLOGS, + PrefKeys.MEDIA_PREVIEW_ENABLED, + PrefKeys.SHOW_BOT_OVERLAY, + PrefKeys.USE_BLURHASH, + PrefKeys.WELLBEING_HIDE_STATS_POSTS + ) + + fun from(preferences: SharedPreferences, account: AccountEntity) = StatusDisplayOptions( + animateAvatars = preferences.getBoolean(PrefKeys.ANIMATE_GIF_AVATARS, false), + animateEmojis = preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false), + mediaPreviewEnabled = account.mediaPreviewEnabled, + useAbsoluteTime = preferences.getBoolean(PrefKeys.ABSOLUTE_TIME_VIEW, false), + showBotOverlay = preferences.getBoolean(PrefKeys.SHOW_BOT_OVERLAY, true), + useBlurhash = preferences.getBoolean(PrefKeys.USE_BLURHASH, true), + cardViewMode = CardViewMode.NONE, + confirmReblogs = preferences.getBoolean(PrefKeys.CONFIRM_REBLOGS, true), + confirmFavourites = preferences.getBoolean(PrefKeys.CONFIRM_FAVOURITES, false), + hideStats = preferences.getBoolean(PrefKeys.WELLBEING_HIDE_STATS_POSTS, false), + showSensitiveMedia = account.alwaysShowSensitiveMedia, + openSpoiler = account.alwaysOpenSpoiler + ) + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/util/ViewDataUtils.kt b/app/src/main/java/com/keylesspalace/tusky/util/ViewDataUtils.kt index 516465120..37e0854be 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/ViewDataUtils.kt +++ b/app/src/main/java/com/keylesspalace/tusky/util/ViewDataUtils.kt @@ -1,3 +1,20 @@ +/* + * Copyright 2023 Tusky Contributors + * + * This file is a part of Tusky. + * + * This program is free software; you can redistribute it and/or modify it under the terms of the + * GNU General Public License as published by the Free Software Foundation; either version 3 of the + * License, or (at your option) any later version. + * + * Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even + * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General + * Public License for more details. + * + * You should have received a copy of the GNU General Public License along with Tusky; if not, + * see . + */ + @file:JvmName("ViewDataUtils") /* Copyright 2017 Andrew Dawson @@ -44,8 +61,8 @@ fun Notification.toViewData( isShowingContent: Boolean, isExpanded: Boolean, isCollapsed: Boolean -): NotificationViewData.Concrete { - return NotificationViewData.Concrete( +): NotificationViewData { + return NotificationViewData( this.type, this.id, this.account, diff --git a/app/src/main/java/com/keylesspalace/tusky/viewdata/NotificationViewData.java b/app/src/main/java/com/keylesspalace/tusky/viewdata/NotificationViewData.java deleted file mode 100644 index c70e2fc71..000000000 --- a/app/src/main/java/com/keylesspalace/tusky/viewdata/NotificationViewData.java +++ /dev/null @@ -1,138 +0,0 @@ -/* Copyright 2017 Andrew Dawson - * - * This file is a part of Tusky. - * - * This program is free software; you can redistribute it and/or modify it under the terms of the - * GNU General Public License as published by the Free Software Foundation; either version 3 of the - * License, or (at your option) any later version. - * - * Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even - * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General - * Public License for more details. - * - * You should have received a copy of the GNU General Public License along with Tusky; if not, - * see . */ - -package com.keylesspalace.tusky.viewdata; - -import androidx.annotation.Nullable; - -import com.keylesspalace.tusky.entity.Notification; -import com.keylesspalace.tusky.entity.Report; -import com.keylesspalace.tusky.entity.TimelineAccount; - -import java.util.Objects; - -/** - * Created by charlag on 12/07/2017. - *

- * Class to represent data required to display either a notification or a placeholder. - * It is either a {@link Placeholder} or a {@link Concrete}. - * It is modelled this way because close relationship between placeholder and concrete notification - * is fine in this case. Placeholder case is not modelled as a type of notification because - * invariants would be violated and because it would model domain incorrectly. It is preferable to - * {@link com.keylesspalace.tusky.util.Either} because class hierarchy is cheaper, faster and - * more native. - */ -public abstract class NotificationViewData { - private NotificationViewData() { - } - - public abstract long getViewDataId(); - - public abstract boolean deepEquals(NotificationViewData other); - - public static final class Concrete extends NotificationViewData { - private final Notification.Type type; - private final String id; - private final TimelineAccount account; - @Nullable - private final StatusViewData.Concrete statusViewData; - @Nullable - private final Report report; - - public Concrete(Notification.Type type, String id, TimelineAccount account, - @Nullable StatusViewData.Concrete statusViewData, @Nullable Report report) { - this.type = type; - this.id = id; - this.account = account; - this.statusViewData = statusViewData; - this.report = report; - } - - public Notification.Type getType() { - return type; - } - - public String getId() { - return id; - } - - public TimelineAccount getAccount() { - return account; - } - - @Nullable - public StatusViewData.Concrete getStatusViewData() { - return statusViewData; - } - - @Nullable - public Report getReport() { - return report; - } - - @Override - public long getViewDataId() { - return id.hashCode(); - } - - @Override - public boolean deepEquals(NotificationViewData o) { - if (this == o) return true; - if (o == null || getClass() != o.getClass()) return false; - Concrete concrete = (Concrete) o; - return type == concrete.type && - Objects.equals(id, concrete.id) && - account.getId().equals(concrete.account.getId()) && - (Objects.equals(statusViewData, concrete.statusViewData)) && - (Objects.equals(report, concrete.report)); - } - - @Override - public int hashCode() { - - return Objects.hash(type, id, account, statusViewData); - } - - public Concrete copyWithStatus(@Nullable StatusViewData.Concrete statusViewData) { - return new Concrete(type, id, account, statusViewData, report); - } - } - - public static final class Placeholder extends NotificationViewData { - private final long id; - private final boolean isLoading; - - public Placeholder(long id, boolean isLoading) { - this.id = id; - this.isLoading = isLoading; - } - - public boolean isLoading() { - return isLoading; - } - - @Override - public long getViewDataId() { - return id; - } - - @Override - public boolean deepEquals(NotificationViewData other) { - if (!(other instanceof Placeholder)) return false; - Placeholder that = (Placeholder) other; - return isLoading == that.isLoading && id == that.id; - } - } -} diff --git a/app/src/main/java/com/keylesspalace/tusky/viewdata/NotificationViewData.kt b/app/src/main/java/com/keylesspalace/tusky/viewdata/NotificationViewData.kt new file mode 100644 index 000000000..759d633e2 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/viewdata/NotificationViewData.kt @@ -0,0 +1,43 @@ +/* + * Copyright 2023 Tusky Contributors + * + * This file is a part of Tusky. + * + * This program is free software; you can redistribute it and/or modify it under the terms of the + * GNU General Public License as published by the Free Software Foundation; either version 3 of the + * License, or (at your option) any later version. + * + * Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even + * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General + * Public License for more details. + * + * You should have received a copy of the GNU General Public License along with Tusky; if not, + * see . + */ + +/* + * This file is a part of Tusky. + * + * This program is free software; you can redistribute it and/or modify it under the terms of the + * GNU General Public License as published by the Free Software Foundation; either version 3 of the + * License, or (at your option) any later version. + * + * Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even + * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General + * Public License for more details. + * + * You should have received a copy of the GNU General Public License along with Tusky; if not, + * see . */ +package com.keylesspalace.tusky.viewdata + +import com.keylesspalace.tusky.entity.Notification +import com.keylesspalace.tusky.entity.Report +import com.keylesspalace.tusky.entity.TimelineAccount + +data class NotificationViewData( + val type: Notification.Type, + val id: String, + val account: TimelineAccount, + var statusViewData: StatusViewData.Concrete?, + val report: Report? +) diff --git a/app/src/main/java/com/keylesspalace/tusky/viewdata/StatusViewData.kt b/app/src/main/java/com/keylesspalace/tusky/viewdata/StatusViewData.kt index f7125dc5b..07b7f3db3 100644 --- a/app/src/main/java/com/keylesspalace/tusky/viewdata/StatusViewData.kt +++ b/app/src/main/java/com/keylesspalace/tusky/viewdata/StatusViewData.kt @@ -90,21 +90,6 @@ sealed class StatusViewData { this.isCollapsible = shouldTrimStatus(this.content) } - /** Helper for Java */ - fun copyWithStatus(status: Status): Concrete { - return copy(status = status) - } - - /** Helper for Java */ - fun copyWithExpanded(isExpanded: Boolean): Concrete { - return copy(isExpanded = isExpanded) - } - - /** Helper for Java */ - fun copyWithShowingContent(isShowingContent: Boolean): Concrete { - return copy(isShowingContent = isShowingContent) - } - /** Helper for Java */ fun copyWithCollapsed(isCollapsed: Boolean): Concrete { return copy(isCollapsed = isCollapsed) diff --git a/app/src/main/res/layout-sw640dp/fragment_timeline_notifications.xml b/app/src/main/res/layout-sw640dp/fragment_timeline_notifications.xml index da5a204bd..db912f965 100644 --- a/app/src/main/res/layout-sw640dp/fragment_timeline_notifications.xml +++ b/app/src/main/res/layout-sw640dp/fragment_timeline_notifications.xml @@ -1,4 +1,21 @@ + + - \ No newline at end of file + diff --git a/app/src/main/res/layout/fragment_timeline_notifications.xml b/app/src/main/res/layout/fragment_timeline_notifications.xml index 8609453f2..5386f8ba1 100644 --- a/app/src/main/res/layout/fragment_timeline_notifications.xml +++ b/app/src/main/res/layout/fragment_timeline_notifications.xml @@ -1,4 +1,21 @@ + + + + + + + +