diff --git a/app/src/main/java/com/keylesspalace/tusky/TabData.kt b/app/src/main/java/com/keylesspalace/tusky/TabData.kt index 4760bd5d5..d569502e5 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.TrendingTagsFragment +import com.keylesspalace.tusky.fragment.NotificationsFragment import java.util.Objects /** this would be a good case for a sealed class, but that does not work nice with Room */ 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 446536e6e..2bbdf44e8 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/FollowRequestViewHolder.kt +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/FollowRequestViewHolder.kt @@ -21,12 +21,10 @@ 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.interfaces.LinkListener -import com.keylesspalace.tusky.util.StatusDisplayOptions import com.keylesspalace.tusky.util.emojify import com.keylesspalace.tusky.util.hide import com.keylesspalace.tusky.util.loadAvatar @@ -35,33 +33,12 @@ import com.keylesspalace.tusky.util.setClickableText import com.keylesspalace.tusky.util.show import com.keylesspalace.tusky.util.unicodeWrap import com.keylesspalace.tusky.util.visible -import com.keylesspalace.tusky.viewdata.NotificationViewData class FollowRequestViewHolder( private val binding: ItemFollowRequestBinding, - private val accountActionListener: AccountActionListener, private val linkListener: LinkListener, private val showHeader: Boolean -) : 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) - } +) : RecyclerView.ViewHolder(binding.root) { fun setupWithAccount( account: TimelineAccount, @@ -70,24 +47,12 @@ 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) diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/NotificationsAdapter.java b/app/src/main/java/com/keylesspalace/tusky/adapter/NotificationsAdapter.java new file mode 100644 index 000000000..a73b52ef9 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/NotificationsAdapter.java @@ -0,0 +1,713 @@ +/* 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 implements LinkListener{ + + 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, this, true); + } + case VIEW_TYPE_PLACEHOLDER: { + View view = inflater + .inflate(R.layout.item_status_placeholder, parent, false); + return new PlaceholderViewHolder(view); + } + case VIEW_TYPE_REPORT: { + ItemReportNotificationBinding binding = ItemReportNotificationBinding.inflate(inflater, parent, false); + return new ReportNotificationViewHolder(binding); + } + default: + case VIEW_TYPE_UNKNOWN: { + View view = new View(parent.getContext()); + view.setLayoutParams( + new ViewGroup.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + Utils.dpToPx(parent.getContext(), 24) + ) + ); + return new RecyclerView.ViewHolder(view) { + }; + } + } + } + + @Override + public void onBindViewHolder(@NonNull RecyclerView.ViewHolder viewHolder, int position) { + bindViewHolder(viewHolder, position, null); + } + + @Override + public void onBindViewHolder(@NonNull RecyclerView.ViewHolder viewHolder, int position, @NonNull List 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(), + statusDisplayOptions.showStatsInline(), + statusDisplayOptions.showSensitiveMedia(), + statusDisplayOptions.openSpoiler() + ); + } + + public boolean isMediaPreviewEnabled() { + return this.statusDisplayOptions.mediaPreviewEnabled(); + } + + @Override + public int getItemViewType(int position) { + NotificationViewData notification = dataSource.getItemAt(position); + if (notification instanceof NotificationViewData.Concrete) { + NotificationViewData.Concrete concrete = ((NotificationViewData.Concrete) notification); + switch (concrete.getType()) { + case MENTION: + case POLL: { + return VIEW_TYPE_STATUS; + } + case STATUS: + case FAVOURITE: + case REBLOG: + case UPDATE: { + return VIEW_TYPE_STATUS_NOTIFICATION; + } + case FOLLOW: + case SIGN_UP: { + return VIEW_TYPE_FOLLOW; + } + case FOLLOW_REQUEST: { + return VIEW_TYPE_FOLLOW_REQUEST; + } + case REPORT: { + return VIEW_TYPE_REPORT; + } + default: { + return VIEW_TYPE_UNKNOWN; + } + } + } else if (notification instanceof NotificationViewData.Placeholder) { + return VIEW_TYPE_PLACEHOLDER; + } else { + throw new AssertionError("Unknown notification type"); + } + + + } + + public interface NotificationActionListener { + void onViewAccount(String id); + + void onViewStatusForNotificationId(String notificationId); + + void onViewReport(String reportId); + + void onExpandedChange(boolean expanded, int position); + + /** + * Called when the status {@link android.widget.ToggleButton} responsible for collapsing long + * status content is interacted with. + * + * @param isCollapsed Whether the status content is shown in a collapsed state or fully. + * @param position The position of the status in the list. + */ + void onNotificationContentCollapsedChange(boolean isCollapsed, int position); + } + + private static class FollowViewHolder extends RecyclerView.ViewHolder { + private final TextView message; + private final TextView usernameView; + private final TextView displayNameView; + private final ImageView avatar; + private final StatusDisplayOptions statusDisplayOptions; + + FollowViewHolder(View itemView, StatusDisplayOptions statusDisplayOptions) { + super(itemView); + message = itemView.findViewById(R.id.notification_text); + usernameView = itemView.findViewById(R.id.notification_username); + displayNameView = itemView.findViewById(R.id.notification_display_name); + avatar = itemView.findViewById(R.id.notification_avatar); + this.statusDisplayOptions = statusDisplayOptions; + } + + void setMessage(TimelineAccount account, Boolean isSignUp) { + Context context = message.getContext(); + + String format = context.getString(isSignUp ? R.string.notification_sign_up_format : R.string.notification_follow_format); + String wrappedDisplayName = StringUtils.unicodeWrap(account.getName()); + String wholeMessage = String.format(format, wrappedDisplayName); + CharSequence emojifiedMessage = CustomEmojiHelper.emojify( + wholeMessage, account.getEmojis(), message, statusDisplayOptions.animateEmojis() + ); + message.setText(emojifiedMessage); + + String username = context.getString(R.string.post_username_format, account.getUsername()); + usernameView.setText(username); + + CharSequence emojifiedDisplayName = CustomEmojiHelper.emojify( + wrappedDisplayName, account.getEmojis(), usernameView, statusDisplayOptions.animateEmojis() + ); + + displayNameView.setText(emojifiedDisplayName); + + int avatarRadius = avatar.getContext().getResources() + .getDimensionPixelSize(R.dimen.avatar_radius_42dp); + + ImageLoadingHelper.loadAvatar(account.getAvatar(), avatar, avatarRadius, + statusDisplayOptions.animateAvatars(), null); + + } + + void setupButtons(final NotificationActionListener listener, final String accountId) { + itemView.setOnClickListener(v -> listener.onViewAccount(accountId)); + } + } + + private static class StatusNotificationViewHolder extends RecyclerView.ViewHolder + implements View.OnClickListener { + + private final View container; + private final TextView message; +// private final View statusNameBar; + private final TextView displayName; + private final TextView username; + private final TextView timestampInfo; + private final TextView statusContent; + private final ImageView statusAvatar; + private final ImageView notificationAvatar; + private final TextView contentWarningDescriptionTextView; + private final Button contentWarningButton; + private final Button contentCollapseButton; // TODO: This code SHOULD be based on StatusBaseViewHolder + private final StatusDisplayOptions statusDisplayOptions; + private final AbsoluteTimeFormatter absoluteTimeFormatter; + + private String accountId; + private String notificationId; + private NotificationActionListener notificationActionListener; + private StatusViewData.Concrete statusViewData; + + private final int avatarRadius48dp; + private final int avatarRadius36dp; + private final int avatarRadius24dp; + + StatusNotificationViewHolder( + View itemView, + StatusDisplayOptions statusDisplayOptions, + AbsoluteTimeFormatter absoluteTimeFormatter + ) { + super(itemView); + message = itemView.findViewById(R.id.notification_top_text); +// statusNameBar = itemView.findViewById(R.id.status_name_bar); + displayName = itemView.findViewById(R.id.status_display_name); + username = itemView.findViewById(R.id.status_username); + timestampInfo = itemView.findViewById(R.id.status_meta_info); + statusContent = itemView.findViewById(R.id.notification_content); + statusAvatar = itemView.findViewById(R.id.notification_status_avatar); + notificationAvatar = itemView.findViewById(R.id.notification_notification_avatar); + contentWarningDescriptionTextView = itemView.findViewById(R.id.notification_content_warning_description); + contentWarningButton = itemView.findViewById(R.id.notification_content_warning_button); + contentCollapseButton = itemView.findViewById(R.id.button_toggle_notification_content); + + container = itemView.findViewById(R.id.notification_container); + + this.statusDisplayOptions = statusDisplayOptions; + this.absoluteTimeFormatter = absoluteTimeFormatter; + + int darkerFilter = Color.rgb(123, 123, 123); + statusAvatar.setColorFilter(darkerFilter, PorterDuff.Mode.MULTIPLY); + notificationAvatar.setColorFilter(darkerFilter, PorterDuff.Mode.MULTIPLY); + + itemView.setOnClickListener(this); + message.setOnClickListener(this); + statusContent.setOnClickListener(this); + + this.avatarRadius48dp = itemView.getContext().getResources().getDimensionPixelSize(R.dimen.avatar_radius_48dp); + this.avatarRadius36dp = itemView.getContext().getResources().getDimensionPixelSize(R.dimen.avatar_radius_36dp); + this.avatarRadius24dp = itemView.getContext().getResources().getDimensionPixelSize(R.dimen.avatar_radius_24dp); + } + + private void showNotificationContent(boolean show) { +// statusNameBar.setVisibility(show ? View.VISIBLE : View.GONE); + contentWarningDescriptionTextView.setVisibility(show ? View.VISIBLE : View.GONE); + contentWarningButton.setVisibility(show ? View.VISIBLE : View.GONE); + statusContent.setVisibility(show ? View.VISIBLE : View.GONE); + statusAvatar.setVisibility(show ? View.VISIBLE : View.GONE); + notificationAvatar.setVisibility(show ? View.VISIBLE : View.GONE); + } + + private void setDisplayName(String name, List 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(), null); + + if (statusDisplayOptions.showBotOverlay() && isBot) { + notificationAvatar.setVisibility(View.VISIBLE); + Glide.with(notificationAvatar) + .load(R.drawable.bot_badge) + .into(notificationAvatar); + + } else { + notificationAvatar.setVisibility(View.GONE); + } + } + + void setAvatars(@Nullable String statusAvatarUrl, @Nullable String notificationAvatarUrl) { + int padding = Utils.dpToPx(statusAvatar.getContext(), 12); + statusAvatar.setPaddingRelative(0, 0, padding, padding); + + ImageLoadingHelper.loadAvatar(statusAvatarUrl, + statusAvatar, avatarRadius36dp, statusDisplayOptions.animateAvatars(), null); + + notificationAvatar.setVisibility(View.VISIBLE); + ImageLoadingHelper.loadAvatar(notificationAvatarUrl, notificationAvatar, + avatarRadius24dp, statusDisplayOptions.animateAvatars(), null); + } + + @Override + public void onClick(View v) { + if (notificationActionListener == null) + return; + + if (v == container || v == statusContent) { + notificationActionListener.onViewStatusForNotificationId(notificationId); + } + else if (v == message) { + notificationActionListener.onViewAccount(accountId); + } + } + + private void setupContentAndSpoiler(final LinkListener listener) { + + boolean shouldShowContentIfSpoiler = statusViewData.isExpanded(); + boolean hasSpoiler = !TextUtils.isEmpty(statusViewData.getStatus(). getSpoilerText()); + if (!shouldShowContentIfSpoiler && hasSpoiler) { + statusContent.setVisibility(View.GONE); + } else { + statusContent.setVisibility(View.VISIBLE); + } + + Spanned content = statusViewData.getContent(); + List 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); + } + + } + + + @Override + public void onViewTag(@NonNull String tag) { + + } + + @Override + public void onViewAccount(@NonNull String id) { + + } + + @Override + public void onViewUrl(@NonNull String url) { + + } +} 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 7502c24e9..db2f79a99 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/ReportNotificationViewHolder.kt +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/ReportNotificationViewHolder.kt @@ -20,76 +20,28 @@ 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.components.notifications.NotificationActionListener -import com.keylesspalace.tusky.components.notifications.NotificationsPagingAdapter +import com.keylesspalace.tusky.adapter.NotificationsAdapter.NotificationActionListener import com.keylesspalace.tusky.databinding.ItemReportNotificationBinding import com.keylesspalace.tusky.entity.Report import com.keylesspalace.tusky.entity.TimelineAccount -import com.keylesspalace.tusky.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, - private val notificationActionListener: NotificationActionListener -) : NotificationsPagingAdapter.ViewHolder, RecyclerView.ViewHolder(binding.root) { +) : 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 - - 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) + 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) 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 @@ -100,22 +52,17 @@ 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, ) } - private fun setupActionListener( - listener: NotificationActionListener, - reporteeId: String, - reporterId: String, - reportId: String - ) { + 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/appstore/EventsHub.kt b/app/src/main/java/com/keylesspalace/tusky/appstore/EventsHub.kt index 4030b116f..231af7bf2 100644 --- a/app/src/main/java/com/keylesspalace/tusky/appstore/EventsHub.kt +++ b/app/src/main/java/com/keylesspalace/tusky/appstore/EventsHub.kt @@ -1,5 +1,7 @@ package com.keylesspalace.tusky.appstore +import io.reactivex.rxjava3.core.Observable +import io.reactivex.rxjava3.subjects.PublishSubject import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow import javax.inject.Inject @@ -13,7 +15,12 @@ class EventHub @Inject constructor() { private val sharedEventFlow: MutableSharedFlow = MutableSharedFlow() val events: Flow = sharedEventFlow + // TODO remove this old stuff as soon as NotificationsFragment is Kotlin + private val eventsSubject = PublishSubject.create() + val eventsObservable: Observable = eventsSubject + suspend fun dispatch(event: Event) { sharedEventFlow.emit(event) + eventsSubject.onNext(event) } } 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 fc860e59e..cdce38142 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 @@ -44,7 +44,6 @@ class FollowRequestsAdapter( ) return FollowRequestViewHolder( binding, - accountActionListener, linkListener, showHeader = false ) 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 deleted file mode 100644 index 70f564c0c..000000000 --- a/app/src/main/java/com/keylesspalace/tusky/components/notifications/FollowViewHolder.kt +++ /dev/null @@ -1,111 +0,0 @@ -/* - * Copyright 2023 Tusky Contributors - * - * This file is a part of Tusky. - * - * This program is free software; you can redistribute it and/or modify it under the terms of the - * GNU General Public License as published by the Free Software Foundation; either version 3 of the - * License, or (at your option) any later version. - * - * Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even - * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General - * Public License for more details. - * - * You should have received a copy of the GNU General Public License along with Tusky; if not, - * see . - */ - -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.interfaces.LinkListener -import com.keylesspalace.tusky.util.StatusDisplayOptions -import com.keylesspalace.tusky.util.emojify -import com.keylesspalace.tusky.util.loadAvatar -import com.keylesspalace.tusky.util.parseAsMastodonHtml -import com.keylesspalace.tusky.util.setClickableText -import com.keylesspalace.tusky.util.unicodeWrap -import com.keylesspalace.tusky.viewdata.NotificationViewData - -class FollowViewHolder( - private val binding: ItemFollowBinding, - private val notificationActionListener: NotificationActionListener, - private val linkListener: LinkListener -) : 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 - ) - - val emojifiedNote = account.note.parseAsMastodonHtml().emojify( - account.emojis, - binding.notificationAccountNote, - animateEmojis - ) - setClickableText(binding.notificationAccountNote, emojifiedNote, emptyList(), null, linkListener) - } - - 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 deleted file mode 100644 index 13d11eeb6..000000000 --- a/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsFragment.kt +++ /dev/null @@ -1,691 +0,0 @@ -/* - * Copyright 2023 Tusky Contributors - * - * This file is a part of Tusky. - * - * This program is free software; you can redistribute it and/or modify it under the terms of the - * GNU General Public License as published by the Free Software Foundation; either version 3 of the - * License, or (at your option) any later version. - * - * Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even - * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General - * Public License for more details. - * - * You should have received a copy of the GNU General Public License along with Tusky; if not, - * see . - */ - -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.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.RecyclerView.NO_POSITION -import androidx.recyclerview.widget.RecyclerView.SCROLL_STATE_IDLE -import androidx.recyclerview.widget.SimpleItemAnimator -import androidx.swiperefreshlayout.widget.SwipeRefreshLayout.OnRefreshListener -import at.connyduck.sparkbutton.helpers.Utils -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 = viewModel.account.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 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().getOrNull(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() - } - } - } - - @Suppress("SyntheticAccessor") - override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) { - newState != SCROLL_STATE_IDLE && return - - // Save the ID of the first notification visible in the list, so the user's - // reading position is always restorable. - layoutManager.findFirstVisibleItemPosition().takeIf { it != NO_POSITION }?.let { position -> - adapter.snapshot().getOrNull(position)?.id?.let { id -> - viewModel.accept(InfallibleUiAction.SaveVisibleId(visibleId = id)) - } - } - } - }) - - binding.recyclerView.adapter = adapter.withLoadStateHeaderAndFooter( - header = NotificationsLoadStateAdapter { adapter.retry() }, - footer = NotificationsLoadStateAdapter { adapter.retry() } - ) - - (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 { - if (getView() != null) { - binding.recyclerView.scrollBy(0, Utils.dpToPx(requireContext(), -30)) - } - } - } - } - }) - - // update post timestamps - val updateTimestampFlow = flow { - while (true) { - delay(60000) - emit(Unit) - } - }.onEach { - adapter.notifyItemRangeChanged(0, adapter.itemCount, 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.throwable.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 != 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 */ - } - } - } - } - - // Collect the uiState. Nothing is done with it, but if you don't collect it then - // accessing viewModel.uiState.value (e.g., when the filter dialog is created) - // returns an empty object. - launch { viewModel.uiState.collect() } - - // Update status display from statusDisplayOptions. If the new options request - // relative time display collect the flow to periodically update the timestamp in the list gui elements. - launch { - viewModel.statusDisplayOptions - .collectLatest { - // NOTE this this also triggered (emitted?) on resume. - - adapter.statusDisplayOptions = it - adapter.notifyItemRangeChanged(0, adapter.itemCount, 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.errorphant_offline, - R.string.error_network - ) { adapter.retry() } - } - else -> { - binding.statusView.setup( - R.drawable.errorphant_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) - val iconColor = MaterialColors.getColor(binding.root, android.R.attr.textColorPrimary) - menu.findItem(R.id.action_refresh)?.apply { - icon = IconicsDrawable(requireContext(), GoogleMaterial.Icon.gmd_refresh).apply { - sizeDp = 20 - colorInt = iconColor - } - } - menu.findItem(R.id.action_edit_notification_filter)?.apply { - icon = IconicsDrawable(requireContext(), GoogleMaterial.Icon.gmd_tune).apply { - sizeDp = 20 - colorInt = iconColor - } - } - } - - override fun onMenuItemSelected(menuItem: MenuItem): Boolean { - return when (menuItem.itemId) { - R.id.action_refresh -> { - binding.swipeRefreshLayout.isRefreshing = true - onRefresh() - true - } - R.id.load_newest -> { - viewModel.accept(InfallibleUiAction.LoadNewest) - true - } - R.id.action_edit_notification_filter -> { - showFilterDialog() - true - } - R.id.action_clear_notifications -> { - confirmClearNotifications() - true - } - else -> false - } - } - - override fun onRefresh() { - binding.progressBar.isVisible = false - adapter.refresh() - NotificationHelper.clearNotificationsForAccount(requireContext(), viewModel.account) - } - - 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().getOrNull(position)?.id?.let { id -> - viewModel.accept(InfallibleUiAction.SaveVisibleId(visibleId = id)) - } - } - } - - override fun onResume() { - super.onResume() - NotificationHelper.clearNotificationsForAccount(requireContext(), viewModel.account) - } - - 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, viewModel.statusDisplayOptions.value.showSensitiveMedia), - 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) - } - - override fun clearWarningAction(position: Int) { - } - - 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://${viewModel.account.domain}/admin/reports/$reportId" - ) - } - - public override fun removeItem(position: Int) { - // Empty -- this fragment doesn't remove items - } - - override fun onReselect() { - if (isAdded) { - layoutManager.scrollToPosition(0) - } - } - - companion object { - private const val TAG = "NotificationsFragment" - 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/NotificationsPagingAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsPagingAdapter.kt deleted file mode 100644 index 8f45a56f2..000000000 --- a/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsPagingAdapter.kt +++ /dev/null @@ -1,207 +0,0 @@ -/* - * Copyright 2023 Tusky Contributors - * - * This file is a part of Tusky. - * - * This program is free software; you can redistribute it and/or modify it under the terms of the - * GNU General Public License as published by the Free Software Foundation; either version 3 of the - * License, or (at your option) any later version. - * - * Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even - * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General - * Public License for more details. - * - * You should have received a copy of the GNU General Public License along with Tusky; if not, - * see . - */ - -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 - ) - } - - 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.entries[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, - statusActionListener - ) - } - NotificationViewKind.FOLLOW_REQUEST -> { - FollowRequestViewHolder( - ItemFollowRequestBinding.inflate(inflater, parent, false), - accountActionListener, - statusActionListener, - 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/StatusNotificationViewHolder.kt b/app/src/main/java/com/keylesspalace/tusky/components/notifications/StatusNotificationViewHolder.kt deleted file mode 100644 index 0b1d8dca4..000000000 --- a/app/src/main/java/com/keylesspalace/tusky/components/notifications/StatusNotificationViewHolder.kt +++ /dev/null @@ -1,387 +0,0 @@ -/* - * Copyright 2023 Tusky Contributors - * - * This file is a part of Tusky. - * - * This program is free software; you can redistribute it and/or modify it under the terms of the - * GNU General Public License as published by the Free Software Foundation; either version 3 of the - * License, or (at your option) any later version. - * - * Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even - * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General - * Public License for more details. - * - * You should have received a copy of the GNU General Public License along with Tusky; if not, - * see . - */ - -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.statusDisplayName.visibility = if (show) View.VISIBLE else View.GONE - binding.statusUsername.visibility = if (show) View.VISIBLE else View.GONE - binding.statusMetaInfo.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 deleted file mode 100644 index c719c084a..000000000 --- a/app/src/main/java/com/keylesspalace/tusky/components/notifications/StatusViewHolder.kt +++ /dev/null @@ -1,60 +0,0 @@ -/* - * Copyright 2023 Tusky Contributors - * - * This file is a part of Tusky. - * - * This program is free software; you can redistribute it and/or modify it under the terms of the - * GNU General Public License as published by the Free Software Foundation; either version 3 of the - * License, or (at your option) any later version. - * - * Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even - * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General - * Public License for more details. - * - * You should have received a copy of the GNU General Public License along with Tusky; if not, - * see . - */ - -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/di/FragmentBuildersModule.kt b/app/src/main/java/com/keylesspalace/tusky/di/FragmentBuildersModule.kt index 710ab75af..825eff6cf 100644 --- a/app/src/main/java/com/keylesspalace/tusky/di/FragmentBuildersModule.kt +++ b/app/src/main/java/com/keylesspalace/tusky/di/FragmentBuildersModule.kt @@ -21,7 +21,6 @@ import com.keylesspalace.tusky.components.account.media.AccountMediaFragment import com.keylesspalace.tusky.components.accountlist.AccountListFragment import com.keylesspalace.tusky.components.conversation.ConversationsFragment import com.keylesspalace.tusky.components.domainblocks.DomainBlocksFragment -import com.keylesspalace.tusky.components.notifications.NotificationsFragment import com.keylesspalace.tusky.components.preference.AccountPreferencesFragment import com.keylesspalace.tusky.components.preference.NotificationPreferencesFragment import com.keylesspalace.tusky.components.preference.PreferencesFragment @@ -35,6 +34,7 @@ import com.keylesspalace.tusky.components.timeline.TimelineFragment import com.keylesspalace.tusky.components.trending.TrendingTagsFragment import com.keylesspalace.tusky.components.viewthread.ViewThreadFragment import com.keylesspalace.tusky.components.viewthread.edits.ViewEditsFragment +import com.keylesspalace.tusky.fragment.NotificationsFragment import com.keylesspalace.tusky.fragment.ViewVideoFragment import dagger.Module import dagger.android.ContributesAndroidInjector 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 a7007e57c..05f992e16 100644 --- a/app/src/main/java/com/keylesspalace/tusky/entity/Notification.kt +++ b/app/src/main/java/com/keylesspalace/tusky/entity/Notification.kt @@ -110,6 +110,9 @@ 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 new file mode 100644 index 000000000..1af1ed438 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/fragment/NotificationsFragment.java @@ -0,0 +1,1278 @@ +/* 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), + preferences.getBoolean(PrefKeys.SHOW_STATS_INLINE, false), + accountManager.getActiveAccount().getAlwaysShowSensitiveMedia(), + accountManager.getActiveAccount().getAlwaysOpenSpoiler() + ); + + 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(); + + 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.getEventsObservable() + .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) +// ); + } + + @Override + public void clearWarningAction(int position) { + + } + + 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, accountManager.getActiveAccount().getAlwaysShowSensitiveMedia()), 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.clearNotificationsOld() + .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.getVisibleTypes(); + 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.getVisibleTypes(); + 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.notificationsOld(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.errorphant_offline, R.string.error_network, __ -> { + binding.progressBar.setVisibility(View.VISIBLE); + this.onRefresh(); + return Unit.INSTANCE; + }); + } else { + binding.statusView.setup(R.drawable.errorphant_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.clearNotificationsForAccount(requireContext(), accountManager.getActiveAccount()); + + 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 3976f5395..2dbaf059c 100644 --- a/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.kt +++ b/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.kt @@ -144,6 +144,14 @@ interface MastodonApi { @Query("exclude_types[]") excludes: Set? = null ): Response> + @GET("api/v1/notifications") + fun notificationsOld( + @Query("max_id") maxId: String?, + @Query("since_id") sinceId: String?, + @Query("limit") limit: Int?, + @Query("exclude_types[]") excludes: Set? + ): Single>> + /** Fetch a single notification */ @GET("api/v1/notifications/{id}") suspend fun notification( @@ -177,6 +185,9 @@ interface MastodonApi { @POST("api/v1/notifications/clear") suspend fun clearNotifications(): Response + @POST("api/v1/notifications/clear") + fun clearNotificationsOld(): Single + @FormUrlEncoded @PUT("api/v1/media/{mediaId}") suspend fun updateMedia( diff --git a/app/src/main/java/com/keylesspalace/tusky/util/PairedList.kt b/app/src/main/java/com/keylesspalace/tusky/util/PairedList.kt new file mode 100644 index 000000000..39a47cc70 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/util/PairedList.kt @@ -0,0 +1,74 @@ +package com.keylesspalace.tusky.util + +import androidx.arch.core.util.Function + +/** + * This list implementation can help to keep two lists in sync - like real models and view models. + * + * Every operation on the main list triggers update of the supplementary list (but not vice versa). + * + * This makes sure that the main list is always the source of truth. + * + * Main list is projected to the supplementary list by the passed mapper function. + * + * Paired list is newer actually exposed and clients are provided with `getPairedCopy()`, + * `getPairedItem()` and `setPairedItem()`. This prevents modifications of the + * supplementary list size so lists are always have the same length. + * + * This implementation will not try to recover from exceptional cases so lists may be out of sync + * after the exception. + * + * It is most useful with immutable data because we cannot track changes inside stored objects. + * + * @param T type of elements in the main list + * @param V type of elements in supplementary list + * @param mapper Function, which will be used to translate items from the main list to the + * supplementary one. + * @constructor + */ +class PairedList (private val mapper: Function) : AbstractMutableList() { + private val main: MutableList = ArrayList() + private val synced: MutableList = ArrayList() + + val pairedCopy: List + get() = ArrayList(synced) + + fun getPairedItem(index: Int): V { + return synced[index] + } + + fun getPairedItemOrNull(index: Int): V? { + return synced.getOrNull(index) + } + + fun setPairedItem(index: Int, element: V) { + synced[index] = element + } + + override fun get(index: Int): T { + return main[index] + } + + override fun set(index: Int, element: T): T { + synced[index] = mapper.apply(element) + return main.set(index, element) + } + + override fun add(element: T): Boolean { + synced.add(mapper.apply(element)) + return main.add(element) + } + + override fun add(index: Int, element: T) { + synced.add(index, mapper.apply(element)) + main.add(index, element) + } + + override fun removeAt(index: Int): T { + synced.removeAt(index) + return main.removeAt(index) + } + + override val size: Int + get() = main.size +} 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 f6ae9e7d7..5c1f3ebc2 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/ViewDataUtils.kt +++ b/app/src/main/java/com/keylesspalace/tusky/util/ViewDataUtils.kt @@ -55,12 +55,13 @@ fun Status.toViewData( ) } +@JvmName("notificationToViewData") fun Notification.toViewData( isShowingContent: Boolean, isExpanded: Boolean, isCollapsed: Boolean -): NotificationViewData { - return NotificationViewData( +): NotificationViewData.Concrete { + return NotificationViewData.Concrete( 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 new file mode 100644 index 000000000..c70e2fc71 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/viewdata/NotificationViewData.java @@ -0,0 +1,138 @@ +/* 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 index 759d633e2..88887154d 100644 --- a/app/src/main/java/com/keylesspalace/tusky/viewdata/NotificationViewData.kt +++ b/app/src/main/java/com/keylesspalace/tusky/viewdata/NotificationViewData.kt @@ -34,7 +34,7 @@ import com.keylesspalace.tusky.entity.Notification import com.keylesspalace.tusky.entity.Report import com.keylesspalace.tusky.entity.TimelineAccount -data class NotificationViewData( +data class NotificationViewDataX( val type: Notification.Type, val id: String, val account: TimelineAccount, 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 5ef3ef373..18e504ec7 100644 --- a/app/src/main/java/com/keylesspalace/tusky/viewdata/StatusViewData.kt +++ b/app/src/main/java/com/keylesspalace/tusky/viewdata/StatusViewData.kt @@ -92,6 +92,21 @@ 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/notifications_filter.xml b/app/src/main/res/layout/notifications_filter.xml new file mode 100644 index 000000000..20aa5f43b --- /dev/null +++ b/app/src/main/res/layout/notifications_filter.xml @@ -0,0 +1,19 @@ + + + +