Resets the paging3 changes of 3159 back to the (java) fragment code before.
Should be the basis for further not-so-rattling improvements.
This commit is contained in:
parent
40d771d60f
commit
add62129f8
@ -20,10 +20,10 @@ import androidx.annotation.DrawableRes
|
|||||||
import androidx.annotation.StringRes
|
import androidx.annotation.StringRes
|
||||||
import androidx.fragment.app.Fragment
|
import androidx.fragment.app.Fragment
|
||||||
import com.keylesspalace.tusky.components.conversation.ConversationsFragment
|
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.TimelineFragment
|
||||||
import com.keylesspalace.tusky.components.timeline.viewmodel.TimelineViewModel
|
import com.keylesspalace.tusky.components.timeline.viewmodel.TimelineViewModel
|
||||||
import com.keylesspalace.tusky.components.trending.TrendingTagsFragment
|
import com.keylesspalace.tusky.components.trending.TrendingTagsFragment
|
||||||
|
import com.keylesspalace.tusky.fragment.NotificationsFragment
|
||||||
import java.util.Objects
|
import java.util.Objects
|
||||||
|
|
||||||
/** this would be a good case for a sealed class, but that does not work nice with Room */
|
/** this would be a good case for a sealed class, but that does not work nice with Room */
|
||||||
|
@ -21,12 +21,10 @@ import android.text.Spanned
|
|||||||
import android.text.style.StyleSpan
|
import android.text.style.StyleSpan
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
import com.keylesspalace.tusky.R
|
import com.keylesspalace.tusky.R
|
||||||
import com.keylesspalace.tusky.components.notifications.NotificationsPagingAdapter
|
|
||||||
import com.keylesspalace.tusky.databinding.ItemFollowRequestBinding
|
import com.keylesspalace.tusky.databinding.ItemFollowRequestBinding
|
||||||
import com.keylesspalace.tusky.entity.TimelineAccount
|
import com.keylesspalace.tusky.entity.TimelineAccount
|
||||||
import com.keylesspalace.tusky.interfaces.AccountActionListener
|
import com.keylesspalace.tusky.interfaces.AccountActionListener
|
||||||
import com.keylesspalace.tusky.interfaces.LinkListener
|
import com.keylesspalace.tusky.interfaces.LinkListener
|
||||||
import com.keylesspalace.tusky.util.StatusDisplayOptions
|
|
||||||
import com.keylesspalace.tusky.util.emojify
|
import com.keylesspalace.tusky.util.emojify
|
||||||
import com.keylesspalace.tusky.util.hide
|
import com.keylesspalace.tusky.util.hide
|
||||||
import com.keylesspalace.tusky.util.loadAvatar
|
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.show
|
||||||
import com.keylesspalace.tusky.util.unicodeWrap
|
import com.keylesspalace.tusky.util.unicodeWrap
|
||||||
import com.keylesspalace.tusky.util.visible
|
import com.keylesspalace.tusky.util.visible
|
||||||
import com.keylesspalace.tusky.viewdata.NotificationViewData
|
|
||||||
|
|
||||||
class FollowRequestViewHolder(
|
class FollowRequestViewHolder(
|
||||||
private val binding: ItemFollowRequestBinding,
|
private val binding: ItemFollowRequestBinding,
|
||||||
private val accountActionListener: AccountActionListener,
|
|
||||||
private val linkListener: LinkListener,
|
private val linkListener: LinkListener,
|
||||||
private val showHeader: Boolean
|
private val showHeader: Boolean
|
||||||
) : 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
|
|
||||||
|
|
||||||
setupWithAccount(
|
|
||||||
viewData.account,
|
|
||||||
statusDisplayOptions.animateAvatars,
|
|
||||||
statusDisplayOptions.animateEmojis,
|
|
||||||
statusDisplayOptions.showBotOverlay
|
|
||||||
)
|
|
||||||
|
|
||||||
setupActionListener(accountActionListener, viewData.account.id)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setupWithAccount(
|
fun setupWithAccount(
|
||||||
account: TimelineAccount,
|
account: TimelineAccount,
|
||||||
@ -70,24 +47,12 @@ class FollowRequestViewHolder(
|
|||||||
showBotOverlay: Boolean
|
showBotOverlay: Boolean
|
||||||
) {
|
) {
|
||||||
val wrappedName = account.name.unicodeWrap()
|
val wrappedName = account.name.unicodeWrap()
|
||||||
val emojifiedName: CharSequence = wrappedName.emojify(
|
val emojifiedName: CharSequence = wrappedName.emojify(account.emojis, itemView, animateEmojis)
|
||||||
account.emojis,
|
|
||||||
itemView,
|
|
||||||
animateEmojis
|
|
||||||
)
|
|
||||||
binding.displayNameTextView.text = emojifiedName
|
binding.displayNameTextView.text = emojifiedName
|
||||||
if (showHeader) {
|
if (showHeader) {
|
||||||
val wholeMessage: String = itemView.context.getString(
|
val wholeMessage: String = itemView.context.getString(R.string.notification_follow_request_format, wrappedName)
|
||||||
R.string.notification_follow_request_format,
|
|
||||||
wrappedName
|
|
||||||
)
|
|
||||||
binding.notificationTextView.text = SpannableStringBuilder(wholeMessage).apply {
|
binding.notificationTextView.text = SpannableStringBuilder(wholeMessage).apply {
|
||||||
setSpan(
|
setSpan(StyleSpan(Typeface.BOLD), 0, wrappedName.length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
|
||||||
StyleSpan(Typeface.BOLD),
|
|
||||||
0,
|
|
||||||
wrappedName.length,
|
|
||||||
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
|
|
||||||
)
|
|
||||||
}.emojify(account.emojis, itemView, animateEmojis)
|
}.emojify(account.emojis, itemView, animateEmojis)
|
||||||
}
|
}
|
||||||
binding.notificationTextView.visible(showHeader)
|
binding.notificationTextView.visible(showHeader)
|
||||||
|
@ -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 <http://www.gnu.org/licenses>. */
|
||||||
|
|
||||||
|
package com.keylesspalace.tusky.adapter;
|
||||||
|
|
||||||
|
import android.content.Context;
|
||||||
|
import android.graphics.Color;
|
||||||
|
import android.graphics.PorterDuff;
|
||||||
|
import android.graphics.Typeface;
|
||||||
|
import android.graphics.drawable.Drawable;
|
||||||
|
import android.text.InputFilter;
|
||||||
|
import android.text.SpannableStringBuilder;
|
||||||
|
import android.text.Spanned;
|
||||||
|
import android.text.TextUtils;
|
||||||
|
import android.text.style.StyleSpan;
|
||||||
|
import android.view.LayoutInflater;
|
||||||
|
import android.view.View;
|
||||||
|
import android.view.ViewGroup;
|
||||||
|
import android.widget.Button;
|
||||||
|
import android.widget.ImageView;
|
||||||
|
import android.widget.TextView;
|
||||||
|
|
||||||
|
import androidx.annotation.ColorRes;
|
||||||
|
import androidx.annotation.DrawableRes;
|
||||||
|
import androidx.annotation.NonNull;
|
||||||
|
import androidx.annotation.Nullable;
|
||||||
|
import androidx.core.content.ContextCompat;
|
||||||
|
import androidx.recyclerview.widget.RecyclerView;
|
||||||
|
|
||||||
|
import com.bumptech.glide.Glide;
|
||||||
|
import com.keylesspalace.tusky.R;
|
||||||
|
import com.keylesspalace.tusky.databinding.ItemFollowRequestBinding;
|
||||||
|
import com.keylesspalace.tusky.databinding.ItemReportNotificationBinding;
|
||||||
|
import com.keylesspalace.tusky.entity.Emoji;
|
||||||
|
import com.keylesspalace.tusky.entity.Notification;
|
||||||
|
import com.keylesspalace.tusky.entity.Status;
|
||||||
|
import com.keylesspalace.tusky.entity.TimelineAccount;
|
||||||
|
import com.keylesspalace.tusky.interfaces.AccountActionListener;
|
||||||
|
import com.keylesspalace.tusky.interfaces.LinkListener;
|
||||||
|
import com.keylesspalace.tusky.interfaces.StatusActionListener;
|
||||||
|
import com.keylesspalace.tusky.util.AbsoluteTimeFormatter;
|
||||||
|
import com.keylesspalace.tusky.util.CardViewMode;
|
||||||
|
import com.keylesspalace.tusky.util.CustomEmojiHelper;
|
||||||
|
import com.keylesspalace.tusky.util.ImageLoadingHelper;
|
||||||
|
import com.keylesspalace.tusky.util.LinkHelper;
|
||||||
|
import com.keylesspalace.tusky.util.SmartLengthInputFilter;
|
||||||
|
import com.keylesspalace.tusky.util.StatusDisplayOptions;
|
||||||
|
import com.keylesspalace.tusky.util.StringUtils;
|
||||||
|
import com.keylesspalace.tusky.util.TimestampUtils;
|
||||||
|
import com.keylesspalace.tusky.viewdata.NotificationViewData;
|
||||||
|
import com.keylesspalace.tusky.viewdata.StatusViewData;
|
||||||
|
|
||||||
|
import java.util.Date;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import at.connyduck.sparkbutton.helpers.Utils;
|
||||||
|
|
||||||
|
public class NotificationsAdapter extends RecyclerView.Adapter<RecyclerView.ViewHolder> implements LinkListener{
|
||||||
|
|
||||||
|
public interface AdapterDataSource<T> {
|
||||||
|
int getItemCount();
|
||||||
|
|
||||||
|
T getItemAt(int pos);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
private static final int VIEW_TYPE_STATUS = 0;
|
||||||
|
private static final int VIEW_TYPE_STATUS_NOTIFICATION = 1;
|
||||||
|
private static final int VIEW_TYPE_FOLLOW = 2;
|
||||||
|
private static final int VIEW_TYPE_FOLLOW_REQUEST = 3;
|
||||||
|
private static final int VIEW_TYPE_PLACEHOLDER = 4;
|
||||||
|
private static final int VIEW_TYPE_REPORT = 5;
|
||||||
|
private static final int VIEW_TYPE_UNKNOWN = 6;
|
||||||
|
|
||||||
|
private static final InputFilter[] COLLAPSE_INPUT_FILTER = new InputFilter[]{SmartLengthInputFilter.INSTANCE};
|
||||||
|
private static final InputFilter[] NO_INPUT_FILTER = new InputFilter[0];
|
||||||
|
|
||||||
|
private final String accountId;
|
||||||
|
private StatusDisplayOptions statusDisplayOptions;
|
||||||
|
private final StatusActionListener statusListener;
|
||||||
|
private final NotificationActionListener notificationActionListener;
|
||||||
|
private final AccountActionListener accountActionListener;
|
||||||
|
private final AdapterDataSource<NotificationViewData> dataSource;
|
||||||
|
private final AbsoluteTimeFormatter absoluteTimeFormatter = new AbsoluteTimeFormatter();
|
||||||
|
|
||||||
|
public NotificationsAdapter(String accountId,
|
||||||
|
AdapterDataSource<NotificationViewData> dataSource,
|
||||||
|
StatusDisplayOptions statusDisplayOptions,
|
||||||
|
StatusActionListener statusListener,
|
||||||
|
NotificationActionListener notificationActionListener,
|
||||||
|
AccountActionListener accountActionListener) {
|
||||||
|
|
||||||
|
this.accountId = accountId;
|
||||||
|
this.dataSource = dataSource;
|
||||||
|
this.statusDisplayOptions = statusDisplayOptions;
|
||||||
|
this.statusListener = statusListener;
|
||||||
|
this.notificationActionListener = notificationActionListener;
|
||||||
|
this.accountActionListener = accountActionListener;
|
||||||
|
}
|
||||||
|
|
||||||
|
@NonNull
|
||||||
|
@Override
|
||||||
|
public RecyclerView.ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
|
||||||
|
LayoutInflater inflater = LayoutInflater.from(parent.getContext());
|
||||||
|
switch (viewType) {
|
||||||
|
case VIEW_TYPE_STATUS: {
|
||||||
|
View view = inflater
|
||||||
|
.inflate(R.layout.item_status, parent, false);
|
||||||
|
return new StatusViewHolder(view);
|
||||||
|
}
|
||||||
|
case VIEW_TYPE_STATUS_NOTIFICATION: {
|
||||||
|
View view = inflater
|
||||||
|
.inflate(R.layout.item_status_notification, parent, false);
|
||||||
|
return new StatusNotificationViewHolder(view, statusDisplayOptions, absoluteTimeFormatter);
|
||||||
|
}
|
||||||
|
case VIEW_TYPE_FOLLOW: {
|
||||||
|
View view = inflater
|
||||||
|
.inflate(R.layout.item_follow, parent, false);
|
||||||
|
return new FollowViewHolder(view, statusDisplayOptions);
|
||||||
|
}
|
||||||
|
case VIEW_TYPE_FOLLOW_REQUEST: {
|
||||||
|
ItemFollowRequestBinding binding = ItemFollowRequestBinding.inflate(inflater, parent, false);
|
||||||
|
return new FollowRequestViewHolder(binding, this, true);
|
||||||
|
}
|
||||||
|
case VIEW_TYPE_PLACEHOLDER: {
|
||||||
|
View view = inflater
|
||||||
|
.inflate(R.layout.item_status_placeholder, parent, false);
|
||||||
|
return new PlaceholderViewHolder(view);
|
||||||
|
}
|
||||||
|
case VIEW_TYPE_REPORT: {
|
||||||
|
ItemReportNotificationBinding binding = ItemReportNotificationBinding.inflate(inflater, parent, false);
|
||||||
|
return new ReportNotificationViewHolder(binding);
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
case VIEW_TYPE_UNKNOWN: {
|
||||||
|
View view = new View(parent.getContext());
|
||||||
|
view.setLayoutParams(
|
||||||
|
new ViewGroup.LayoutParams(
|
||||||
|
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||||
|
Utils.dpToPx(parent.getContext(), 24)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
return new RecyclerView.ViewHolder(view) {
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onBindViewHolder(@NonNull RecyclerView.ViewHolder viewHolder, int position) {
|
||||||
|
bindViewHolder(viewHolder, position, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onBindViewHolder(@NonNull RecyclerView.ViewHolder viewHolder, int position, @NonNull List<Object> payloads) {
|
||||||
|
bindViewHolder(viewHolder, position, payloads);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void bindViewHolder(@NonNull RecyclerView.ViewHolder viewHolder, int position, @Nullable List<Object> payloads) {
|
||||||
|
Object payloadForHolder = payloads != null && !payloads.isEmpty() ? payloads.get(0) : null;
|
||||||
|
if (position < this.dataSource.getItemCount()) {
|
||||||
|
NotificationViewData notification = dataSource.getItemAt(position);
|
||||||
|
if (notification instanceof NotificationViewData.Placeholder) {
|
||||||
|
if (payloadForHolder == null) {
|
||||||
|
NotificationViewData.Placeholder placeholder = ((NotificationViewData.Placeholder) notification);
|
||||||
|
PlaceholderViewHolder holder = (PlaceholderViewHolder) viewHolder;
|
||||||
|
holder.setup(statusListener, placeholder.isLoading());
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
NotificationViewData.Concrete concreteNotification =
|
||||||
|
(NotificationViewData.Concrete) notification;
|
||||||
|
switch (viewHolder.getItemViewType()) {
|
||||||
|
case VIEW_TYPE_STATUS: {
|
||||||
|
StatusViewHolder holder = (StatusViewHolder) viewHolder;
|
||||||
|
StatusViewData.Concrete status = concreteNotification.getStatusViewData();
|
||||||
|
if (status == null) {
|
||||||
|
/* in some very rare cases servers sends null status even though they should not,
|
||||||
|
* we have to handle it somehow */
|
||||||
|
holder.showStatusContent(false);
|
||||||
|
} else {
|
||||||
|
if (payloads == null) {
|
||||||
|
holder.showStatusContent(true);
|
||||||
|
}
|
||||||
|
holder.setupWithStatus(status, statusListener, statusDisplayOptions, payloadForHolder);
|
||||||
|
}
|
||||||
|
if (concreteNotification.getType() == Notification.Type.POLL) {
|
||||||
|
holder.setPollInfo(accountId.equals(concreteNotification.getAccount().getId()));
|
||||||
|
} else {
|
||||||
|
holder.hideStatusInfo();
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case VIEW_TYPE_STATUS_NOTIFICATION: {
|
||||||
|
StatusNotificationViewHolder holder = (StatusNotificationViewHolder) viewHolder;
|
||||||
|
StatusViewData.Concrete statusViewData = concreteNotification.getStatusViewData();
|
||||||
|
if (payloadForHolder == null) {
|
||||||
|
if (statusViewData == null) {
|
||||||
|
/* in some very rare cases servers sends null status even though they should not,
|
||||||
|
* we have to handle it somehow */
|
||||||
|
holder.showNotificationContent(false);
|
||||||
|
} else {
|
||||||
|
holder.showNotificationContent(true);
|
||||||
|
|
||||||
|
Status status = statusViewData.getActionable();
|
||||||
|
holder.setDisplayName(status.getAccount().getDisplayName(), status.getAccount().getEmojis());
|
||||||
|
holder.setUsername(status.getAccount().getUsername());
|
||||||
|
holder.setCreatedAt(status.getCreatedAt());
|
||||||
|
|
||||||
|
if (concreteNotification.getType() == Notification.Type.STATUS ||
|
||||||
|
concreteNotification.getType() == Notification.Type.UPDATE) {
|
||||||
|
holder.setAvatar(status.getAccount().getAvatar(), status.getAccount().getBot());
|
||||||
|
} else {
|
||||||
|
holder.setAvatars(status.getAccount().getAvatar(),
|
||||||
|
concreteNotification.getAccount().getAvatar());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
holder.setMessage(concreteNotification, statusListener);
|
||||||
|
holder.setupButtons(notificationActionListener,
|
||||||
|
concreteNotification.getAccount().getId(),
|
||||||
|
concreteNotification.getId());
|
||||||
|
} else {
|
||||||
|
if (payloadForHolder instanceof List)
|
||||||
|
for (Object item : (List<?>) payloadForHolder) {
|
||||||
|
if (StatusBaseViewHolder.Key.KEY_CREATED.equals(item) && statusViewData != null) {
|
||||||
|
holder.setCreatedAt(statusViewData.getStatus().getActionableStatus().getCreatedAt());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case VIEW_TYPE_FOLLOW: {
|
||||||
|
if (payloadForHolder == null) {
|
||||||
|
FollowViewHolder holder = (FollowViewHolder) viewHolder;
|
||||||
|
holder.setMessage(concreteNotification.getAccount(), concreteNotification.getType() == Notification.Type.SIGN_UP);
|
||||||
|
holder.setupButtons(notificationActionListener, concreteNotification.getAccount().getId());
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case VIEW_TYPE_FOLLOW_REQUEST: {
|
||||||
|
if (payloadForHolder == null) {
|
||||||
|
FollowRequestViewHolder holder = (FollowRequestViewHolder) viewHolder;
|
||||||
|
holder.setupWithAccount(concreteNotification.getAccount(), statusDisplayOptions.animateAvatars(), statusDisplayOptions.animateEmojis(), statusDisplayOptions.showBotOverlay());
|
||||||
|
holder.setupActionListener(accountActionListener, concreteNotification.getAccount().getId());
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case VIEW_TYPE_REPORT: {
|
||||||
|
if (payloadForHolder == null) {
|
||||||
|
ReportNotificationViewHolder holder = (ReportNotificationViewHolder) viewHolder;
|
||||||
|
holder.setupWithReport(concreteNotification.getAccount(), concreteNotification.getReport(), statusDisplayOptions.animateAvatars(), statusDisplayOptions.animateEmojis());
|
||||||
|
holder.setupActionListener(notificationActionListener, concreteNotification.getReport().getTargetAccount().getId(), concreteNotification.getAccount().getId(), concreteNotification.getReport().getId());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int getItemCount() {
|
||||||
|
return dataSource.getItemCount();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setMediaPreviewEnabled(boolean mediaPreviewEnabled) {
|
||||||
|
this.statusDisplayOptions = statusDisplayOptions.copy(
|
||||||
|
statusDisplayOptions.animateAvatars(),
|
||||||
|
mediaPreviewEnabled,
|
||||||
|
statusDisplayOptions.useAbsoluteTime(),
|
||||||
|
statusDisplayOptions.showBotOverlay(),
|
||||||
|
statusDisplayOptions.useBlurhash(),
|
||||||
|
CardViewMode.NONE,
|
||||||
|
statusDisplayOptions.confirmReblogs(),
|
||||||
|
statusDisplayOptions.confirmFavourites(),
|
||||||
|
statusDisplayOptions.hideStats(),
|
||||||
|
statusDisplayOptions.animateEmojis(),
|
||||||
|
statusDisplayOptions.showStatsInline(),
|
||||||
|
statusDisplayOptions.showSensitiveMedia(),
|
||||||
|
statusDisplayOptions.openSpoiler()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isMediaPreviewEnabled() {
|
||||||
|
return this.statusDisplayOptions.mediaPreviewEnabled();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int getItemViewType(int position) {
|
||||||
|
NotificationViewData notification = dataSource.getItemAt(position);
|
||||||
|
if (notification instanceof NotificationViewData.Concrete) {
|
||||||
|
NotificationViewData.Concrete concrete = ((NotificationViewData.Concrete) notification);
|
||||||
|
switch (concrete.getType()) {
|
||||||
|
case MENTION:
|
||||||
|
case POLL: {
|
||||||
|
return VIEW_TYPE_STATUS;
|
||||||
|
}
|
||||||
|
case STATUS:
|
||||||
|
case FAVOURITE:
|
||||||
|
case REBLOG:
|
||||||
|
case UPDATE: {
|
||||||
|
return VIEW_TYPE_STATUS_NOTIFICATION;
|
||||||
|
}
|
||||||
|
case FOLLOW:
|
||||||
|
case SIGN_UP: {
|
||||||
|
return VIEW_TYPE_FOLLOW;
|
||||||
|
}
|
||||||
|
case FOLLOW_REQUEST: {
|
||||||
|
return VIEW_TYPE_FOLLOW_REQUEST;
|
||||||
|
}
|
||||||
|
case REPORT: {
|
||||||
|
return VIEW_TYPE_REPORT;
|
||||||
|
}
|
||||||
|
default: {
|
||||||
|
return VIEW_TYPE_UNKNOWN;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (notification instanceof NotificationViewData.Placeholder) {
|
||||||
|
return VIEW_TYPE_PLACEHOLDER;
|
||||||
|
} else {
|
||||||
|
throw new AssertionError("Unknown notification type");
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
public interface NotificationActionListener {
|
||||||
|
void onViewAccount(String id);
|
||||||
|
|
||||||
|
void onViewStatusForNotificationId(String notificationId);
|
||||||
|
|
||||||
|
void onViewReport(String reportId);
|
||||||
|
|
||||||
|
void onExpandedChange(boolean expanded, int position);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called when the status {@link android.widget.ToggleButton} responsible for collapsing long
|
||||||
|
* status content is interacted with.
|
||||||
|
*
|
||||||
|
* @param isCollapsed Whether the status content is shown in a collapsed state or fully.
|
||||||
|
* @param position The position of the status in the list.
|
||||||
|
*/
|
||||||
|
void onNotificationContentCollapsedChange(boolean isCollapsed, int position);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static class FollowViewHolder extends RecyclerView.ViewHolder {
|
||||||
|
private final TextView message;
|
||||||
|
private final TextView usernameView;
|
||||||
|
private final TextView displayNameView;
|
||||||
|
private final ImageView avatar;
|
||||||
|
private final StatusDisplayOptions statusDisplayOptions;
|
||||||
|
|
||||||
|
FollowViewHolder(View itemView, StatusDisplayOptions statusDisplayOptions) {
|
||||||
|
super(itemView);
|
||||||
|
message = itemView.findViewById(R.id.notification_text);
|
||||||
|
usernameView = itemView.findViewById(R.id.notification_username);
|
||||||
|
displayNameView = itemView.findViewById(R.id.notification_display_name);
|
||||||
|
avatar = itemView.findViewById(R.id.notification_avatar);
|
||||||
|
this.statusDisplayOptions = statusDisplayOptions;
|
||||||
|
}
|
||||||
|
|
||||||
|
void setMessage(TimelineAccount account, Boolean isSignUp) {
|
||||||
|
Context context = message.getContext();
|
||||||
|
|
||||||
|
String format = context.getString(isSignUp ? R.string.notification_sign_up_format : R.string.notification_follow_format);
|
||||||
|
String wrappedDisplayName = StringUtils.unicodeWrap(account.getName());
|
||||||
|
String wholeMessage = String.format(format, wrappedDisplayName);
|
||||||
|
CharSequence emojifiedMessage = CustomEmojiHelper.emojify(
|
||||||
|
wholeMessage, account.getEmojis(), message, statusDisplayOptions.animateEmojis()
|
||||||
|
);
|
||||||
|
message.setText(emojifiedMessage);
|
||||||
|
|
||||||
|
String username = context.getString(R.string.post_username_format, account.getUsername());
|
||||||
|
usernameView.setText(username);
|
||||||
|
|
||||||
|
CharSequence emojifiedDisplayName = CustomEmojiHelper.emojify(
|
||||||
|
wrappedDisplayName, account.getEmojis(), usernameView, statusDisplayOptions.animateEmojis()
|
||||||
|
);
|
||||||
|
|
||||||
|
displayNameView.setText(emojifiedDisplayName);
|
||||||
|
|
||||||
|
int avatarRadius = avatar.getContext().getResources()
|
||||||
|
.getDimensionPixelSize(R.dimen.avatar_radius_42dp);
|
||||||
|
|
||||||
|
ImageLoadingHelper.loadAvatar(account.getAvatar(), avatar, avatarRadius,
|
||||||
|
statusDisplayOptions.animateAvatars(), null);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
void setupButtons(final NotificationActionListener listener, final String accountId) {
|
||||||
|
itemView.setOnClickListener(v -> listener.onViewAccount(accountId));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static class StatusNotificationViewHolder extends RecyclerView.ViewHolder
|
||||||
|
implements View.OnClickListener {
|
||||||
|
|
||||||
|
private final View container;
|
||||||
|
private final TextView message;
|
||||||
|
// private final View statusNameBar;
|
||||||
|
private final TextView displayName;
|
||||||
|
private final TextView username;
|
||||||
|
private final TextView timestampInfo;
|
||||||
|
private final TextView statusContent;
|
||||||
|
private final ImageView statusAvatar;
|
||||||
|
private final ImageView notificationAvatar;
|
||||||
|
private final TextView contentWarningDescriptionTextView;
|
||||||
|
private final Button contentWarningButton;
|
||||||
|
private final Button contentCollapseButton; // TODO: This code SHOULD be based on StatusBaseViewHolder
|
||||||
|
private final StatusDisplayOptions statusDisplayOptions;
|
||||||
|
private final AbsoluteTimeFormatter absoluteTimeFormatter;
|
||||||
|
|
||||||
|
private String accountId;
|
||||||
|
private String notificationId;
|
||||||
|
private NotificationActionListener notificationActionListener;
|
||||||
|
private StatusViewData.Concrete statusViewData;
|
||||||
|
|
||||||
|
private final int avatarRadius48dp;
|
||||||
|
private final int avatarRadius36dp;
|
||||||
|
private final int avatarRadius24dp;
|
||||||
|
|
||||||
|
StatusNotificationViewHolder(
|
||||||
|
View itemView,
|
||||||
|
StatusDisplayOptions statusDisplayOptions,
|
||||||
|
AbsoluteTimeFormatter absoluteTimeFormatter
|
||||||
|
) {
|
||||||
|
super(itemView);
|
||||||
|
message = itemView.findViewById(R.id.notification_top_text);
|
||||||
|
// statusNameBar = itemView.findViewById(R.id.status_name_bar);
|
||||||
|
displayName = itemView.findViewById(R.id.status_display_name);
|
||||||
|
username = itemView.findViewById(R.id.status_username);
|
||||||
|
timestampInfo = itemView.findViewById(R.id.status_meta_info);
|
||||||
|
statusContent = itemView.findViewById(R.id.notification_content);
|
||||||
|
statusAvatar = itemView.findViewById(R.id.notification_status_avatar);
|
||||||
|
notificationAvatar = itemView.findViewById(R.id.notification_notification_avatar);
|
||||||
|
contentWarningDescriptionTextView = itemView.findViewById(R.id.notification_content_warning_description);
|
||||||
|
contentWarningButton = itemView.findViewById(R.id.notification_content_warning_button);
|
||||||
|
contentCollapseButton = itemView.findViewById(R.id.button_toggle_notification_content);
|
||||||
|
|
||||||
|
container = itemView.findViewById(R.id.notification_container);
|
||||||
|
|
||||||
|
this.statusDisplayOptions = statusDisplayOptions;
|
||||||
|
this.absoluteTimeFormatter = absoluteTimeFormatter;
|
||||||
|
|
||||||
|
int darkerFilter = Color.rgb(123, 123, 123);
|
||||||
|
statusAvatar.setColorFilter(darkerFilter, PorterDuff.Mode.MULTIPLY);
|
||||||
|
notificationAvatar.setColorFilter(darkerFilter, PorterDuff.Mode.MULTIPLY);
|
||||||
|
|
||||||
|
itemView.setOnClickListener(this);
|
||||||
|
message.setOnClickListener(this);
|
||||||
|
statusContent.setOnClickListener(this);
|
||||||
|
|
||||||
|
this.avatarRadius48dp = itemView.getContext().getResources().getDimensionPixelSize(R.dimen.avatar_radius_48dp);
|
||||||
|
this.avatarRadius36dp = itemView.getContext().getResources().getDimensionPixelSize(R.dimen.avatar_radius_36dp);
|
||||||
|
this.avatarRadius24dp = itemView.getContext().getResources().getDimensionPixelSize(R.dimen.avatar_radius_24dp);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void showNotificationContent(boolean show) {
|
||||||
|
// statusNameBar.setVisibility(show ? View.VISIBLE : View.GONE);
|
||||||
|
contentWarningDescriptionTextView.setVisibility(show ? View.VISIBLE : View.GONE);
|
||||||
|
contentWarningButton.setVisibility(show ? View.VISIBLE : View.GONE);
|
||||||
|
statusContent.setVisibility(show ? View.VISIBLE : View.GONE);
|
||||||
|
statusAvatar.setVisibility(show ? View.VISIBLE : View.GONE);
|
||||||
|
notificationAvatar.setVisibility(show ? View.VISIBLE : View.GONE);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void setDisplayName(String name, List<Emoji> emojis) {
|
||||||
|
CharSequence emojifiedName = CustomEmojiHelper.emojify(name, emojis, displayName, statusDisplayOptions.animateEmojis());
|
||||||
|
displayName.setText(emojifiedName);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void setUsername(String name) {
|
||||||
|
Context context = username.getContext();
|
||||||
|
String format = context.getString(R.string.post_username_format);
|
||||||
|
String usernameText = String.format(format, name);
|
||||||
|
username.setText(usernameText);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected void setCreatedAt(@Nullable Date createdAt) {
|
||||||
|
if (statusDisplayOptions.useAbsoluteTime()) {
|
||||||
|
timestampInfo.setText(absoluteTimeFormatter.format(createdAt, true));
|
||||||
|
} else {
|
||||||
|
// This is the visible timestampInfo.
|
||||||
|
String readout;
|
||||||
|
/* This one is for screen-readers. Frequently, they would mispronounce timestamps like "17m"
|
||||||
|
* as 17 meters instead of minutes. */
|
||||||
|
CharSequence readoutAloud;
|
||||||
|
if (createdAt != null) {
|
||||||
|
long then = createdAt.getTime();
|
||||||
|
long now = new Date().getTime();
|
||||||
|
readout = TimestampUtils.getRelativeTimeSpanString(timestampInfo.getContext(), then, now);
|
||||||
|
readoutAloud = android.text.format.DateUtils.getRelativeTimeSpanString(then, now,
|
||||||
|
android.text.format.DateUtils.SECOND_IN_MILLIS,
|
||||||
|
android.text.format.DateUtils.FORMAT_ABBREV_RELATIVE);
|
||||||
|
} else {
|
||||||
|
// unknown minutes~
|
||||||
|
readout = "?m";
|
||||||
|
readoutAloud = "? minutes";
|
||||||
|
}
|
||||||
|
timestampInfo.setText(readout);
|
||||||
|
timestampInfo.setContentDescription(readoutAloud);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Drawable getIconWithColor(Context context, @DrawableRes int drawable, @ColorRes int color) {
|
||||||
|
Drawable icon = ContextCompat.getDrawable(context, drawable);
|
||||||
|
if (icon != null) {
|
||||||
|
icon.setColorFilter(context.getColor(color), PorterDuff.Mode.SRC_ATOP);
|
||||||
|
}
|
||||||
|
return icon;
|
||||||
|
}
|
||||||
|
|
||||||
|
void setMessage(NotificationViewData.Concrete notificationViewData, LinkListener listener) {
|
||||||
|
this.statusViewData = notificationViewData.getStatusViewData();
|
||||||
|
|
||||||
|
String displayName = StringUtils.unicodeWrap(notificationViewData.getAccount().getName());
|
||||||
|
Notification.Type type = notificationViewData.getType();
|
||||||
|
|
||||||
|
Context context = message.getContext();
|
||||||
|
String format;
|
||||||
|
Drawable icon;
|
||||||
|
switch (type) {
|
||||||
|
default:
|
||||||
|
case FAVOURITE: {
|
||||||
|
icon = getIconWithColor(context, R.drawable.ic_star_24dp, R.color.tusky_orange);
|
||||||
|
format = context.getString(R.string.notification_favourite_format);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case REBLOG: {
|
||||||
|
icon = getIconWithColor(context, R.drawable.ic_repeat_24dp, R.color.tusky_blue);
|
||||||
|
format = context.getString(R.string.notification_reblog_format);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case STATUS: {
|
||||||
|
icon = getIconWithColor(context, R.drawable.ic_home_24dp, R.color.tusky_blue);
|
||||||
|
format = context.getString(R.string.notification_subscription_format);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case UPDATE: {
|
||||||
|
icon = getIconWithColor(context, R.drawable.ic_edit_24dp, R.color.tusky_blue);
|
||||||
|
format = context.getString(R.string.notification_update_format);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
message.setCompoundDrawablesWithIntrinsicBounds(icon, null, null, null);
|
||||||
|
String wholeMessage = String.format(format, displayName);
|
||||||
|
final SpannableStringBuilder str = new SpannableStringBuilder(wholeMessage);
|
||||||
|
int displayNameIndex = format.indexOf("%s");
|
||||||
|
str.setSpan(
|
||||||
|
new StyleSpan(Typeface.BOLD),
|
||||||
|
displayNameIndex,
|
||||||
|
displayNameIndex + displayName.length(),
|
||||||
|
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
|
||||||
|
);
|
||||||
|
CharSequence emojifiedText = CustomEmojiHelper.emojify(
|
||||||
|
str, notificationViewData.getAccount().getEmojis(), message, statusDisplayOptions.animateEmojis()
|
||||||
|
);
|
||||||
|
message.setText(emojifiedText);
|
||||||
|
|
||||||
|
if (statusViewData != null) {
|
||||||
|
boolean hasSpoiler = !TextUtils.isEmpty(statusViewData.getStatus().getSpoilerText());
|
||||||
|
contentWarningDescriptionTextView.setVisibility(hasSpoiler ? View.VISIBLE : View.GONE);
|
||||||
|
contentWarningButton.setVisibility(hasSpoiler ? View.VISIBLE : View.GONE);
|
||||||
|
if (statusViewData.isExpanded()) {
|
||||||
|
contentWarningButton.setText(R.string.post_content_warning_show_less);
|
||||||
|
} else {
|
||||||
|
contentWarningButton.setText(R.string.post_content_warning_show_more);
|
||||||
|
}
|
||||||
|
|
||||||
|
contentWarningButton.setOnClickListener(view -> {
|
||||||
|
if (getBindingAdapterPosition() != RecyclerView.NO_POSITION) {
|
||||||
|
notificationActionListener.onExpandedChange(!statusViewData.isExpanded(), getBindingAdapterPosition());
|
||||||
|
}
|
||||||
|
statusContent.setVisibility(statusViewData.isExpanded() ? View.GONE : View.VISIBLE);
|
||||||
|
});
|
||||||
|
|
||||||
|
setupContentAndSpoiler(listener);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
void setupButtons(final NotificationActionListener listener, final String accountId,
|
||||||
|
final String notificationId) {
|
||||||
|
this.notificationActionListener = listener;
|
||||||
|
this.accountId = accountId;
|
||||||
|
this.notificationId = notificationId;
|
||||||
|
}
|
||||||
|
|
||||||
|
void setAvatar(@Nullable String statusAvatarUrl, boolean isBot) {
|
||||||
|
statusAvatar.setPaddingRelative(0, 0, 0, 0);
|
||||||
|
|
||||||
|
ImageLoadingHelper.loadAvatar(statusAvatarUrl,
|
||||||
|
statusAvatar, avatarRadius48dp, statusDisplayOptions.animateAvatars(), null);
|
||||||
|
|
||||||
|
if (statusDisplayOptions.showBotOverlay() && isBot) {
|
||||||
|
notificationAvatar.setVisibility(View.VISIBLE);
|
||||||
|
Glide.with(notificationAvatar)
|
||||||
|
.load(R.drawable.bot_badge)
|
||||||
|
.into(notificationAvatar);
|
||||||
|
|
||||||
|
} else {
|
||||||
|
notificationAvatar.setVisibility(View.GONE);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void setAvatars(@Nullable String statusAvatarUrl, @Nullable String notificationAvatarUrl) {
|
||||||
|
int padding = Utils.dpToPx(statusAvatar.getContext(), 12);
|
||||||
|
statusAvatar.setPaddingRelative(0, 0, padding, padding);
|
||||||
|
|
||||||
|
ImageLoadingHelper.loadAvatar(statusAvatarUrl,
|
||||||
|
statusAvatar, avatarRadius36dp, statusDisplayOptions.animateAvatars(), null);
|
||||||
|
|
||||||
|
notificationAvatar.setVisibility(View.VISIBLE);
|
||||||
|
ImageLoadingHelper.loadAvatar(notificationAvatarUrl, notificationAvatar,
|
||||||
|
avatarRadius24dp, statusDisplayOptions.animateAvatars(), null);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onClick(View v) {
|
||||||
|
if (notificationActionListener == null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
if (v == container || v == statusContent) {
|
||||||
|
notificationActionListener.onViewStatusForNotificationId(notificationId);
|
||||||
|
}
|
||||||
|
else if (v == message) {
|
||||||
|
notificationActionListener.onViewAccount(accountId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void setupContentAndSpoiler(final LinkListener listener) {
|
||||||
|
|
||||||
|
boolean shouldShowContentIfSpoiler = statusViewData.isExpanded();
|
||||||
|
boolean hasSpoiler = !TextUtils.isEmpty(statusViewData.getStatus(). getSpoilerText());
|
||||||
|
if (!shouldShowContentIfSpoiler && hasSpoiler) {
|
||||||
|
statusContent.setVisibility(View.GONE);
|
||||||
|
} else {
|
||||||
|
statusContent.setVisibility(View.VISIBLE);
|
||||||
|
}
|
||||||
|
|
||||||
|
Spanned content = statusViewData.getContent();
|
||||||
|
List<Emoji> emojis = statusViewData.getActionable().getEmojis();
|
||||||
|
|
||||||
|
if (statusViewData.isCollapsible() && (statusViewData.isExpanded() || !hasSpoiler)) {
|
||||||
|
contentCollapseButton.setOnClickListener(view -> {
|
||||||
|
int position = getBindingAdapterPosition();
|
||||||
|
if (position != RecyclerView.NO_POSITION && notificationActionListener != null) {
|
||||||
|
notificationActionListener.onNotificationContentCollapsedChange(!statusViewData.isCollapsed(), position);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
contentCollapseButton.setVisibility(View.VISIBLE);
|
||||||
|
if (statusViewData.isCollapsed()) {
|
||||||
|
contentCollapseButton.setText(R.string.post_content_warning_show_more);
|
||||||
|
statusContent.setFilters(COLLAPSE_INPUT_FILTER);
|
||||||
|
} else {
|
||||||
|
contentCollapseButton.setText(R.string.post_content_warning_show_less);
|
||||||
|
statusContent.setFilters(NO_INPUT_FILTER);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
contentCollapseButton.setVisibility(View.GONE);
|
||||||
|
statusContent.setFilters(NO_INPUT_FILTER);
|
||||||
|
}
|
||||||
|
|
||||||
|
CharSequence emojifiedText = CustomEmojiHelper.emojify(
|
||||||
|
content, emojis, statusContent, statusDisplayOptions.animateEmojis()
|
||||||
|
);
|
||||||
|
LinkHelper.setClickableText(statusContent, emojifiedText, statusViewData.getActionable().getMentions(), statusViewData.getActionable().getTags(), listener);
|
||||||
|
|
||||||
|
CharSequence emojifiedContentWarning;
|
||||||
|
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) {
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
@ -20,76 +20,28 @@ import androidx.core.content.ContextCompat
|
|||||||
import androidx.recyclerview.widget.RecyclerView
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
import at.connyduck.sparkbutton.helpers.Utils
|
import at.connyduck.sparkbutton.helpers.Utils
|
||||||
import com.keylesspalace.tusky.R
|
import com.keylesspalace.tusky.R
|
||||||
import com.keylesspalace.tusky.components.notifications.NotificationActionListener
|
import com.keylesspalace.tusky.adapter.NotificationsAdapter.NotificationActionListener
|
||||||
import com.keylesspalace.tusky.components.notifications.NotificationsPagingAdapter
|
|
||||||
import com.keylesspalace.tusky.databinding.ItemReportNotificationBinding
|
import com.keylesspalace.tusky.databinding.ItemReportNotificationBinding
|
||||||
import com.keylesspalace.tusky.entity.Report
|
import com.keylesspalace.tusky.entity.Report
|
||||||
import com.keylesspalace.tusky.entity.TimelineAccount
|
import com.keylesspalace.tusky.entity.TimelineAccount
|
||||||
import com.keylesspalace.tusky.util.StatusDisplayOptions
|
|
||||||
import com.keylesspalace.tusky.util.emojify
|
import com.keylesspalace.tusky.util.emojify
|
||||||
import com.keylesspalace.tusky.util.getRelativeTimeSpanString
|
import com.keylesspalace.tusky.util.getRelativeTimeSpanString
|
||||||
import com.keylesspalace.tusky.util.loadAvatar
|
import com.keylesspalace.tusky.util.loadAvatar
|
||||||
import com.keylesspalace.tusky.util.unicodeWrap
|
import com.keylesspalace.tusky.util.unicodeWrap
|
||||||
import com.keylesspalace.tusky.viewdata.NotificationViewData
|
|
||||||
import java.util.Date
|
import java.util.Date
|
||||||
|
|
||||||
class ReportNotificationViewHolder(
|
class ReportNotificationViewHolder(
|
||||||
private val binding: ItemReportNotificationBinding,
|
private val binding: ItemReportNotificationBinding,
|
||||||
private val notificationActionListener: NotificationActionListener
|
) : RecyclerView.ViewHolder(binding.root) {
|
||||||
) : NotificationsPagingAdapter.ViewHolder, RecyclerView.ViewHolder(binding.root) {
|
|
||||||
|
|
||||||
override fun bind(
|
fun setupWithReport(reporter: TimelineAccount, report: Report, animateAvatar: Boolean, animateEmojis: Boolean) {
|
||||||
viewData: NotificationViewData,
|
val reporterName = reporter.name.unicodeWrap().emojify(reporter.emojis, itemView, animateEmojis)
|
||||||
payloads: List<*>?,
|
val reporteeName = report.targetAccount.name.unicodeWrap().emojify(report.targetAccount.emojis, itemView, animateEmojis)
|
||||||
statusDisplayOptions: StatusDisplayOptions
|
val icon = ContextCompat.getDrawable(itemView.context, R.drawable.ic_flag_24dp)
|
||||||
) {
|
|
||||||
// Skip updates with payloads. That indicates a timestamp update, and
|
|
||||||
// this view does not have timestamps.
|
|
||||||
if (!payloads.isNullOrEmpty()) return
|
|
||||||
|
|
||||||
setupWithReport(
|
|
||||||
viewData.account,
|
|
||||||
viewData.report!!,
|
|
||||||
statusDisplayOptions.animateAvatars,
|
|
||||||
statusDisplayOptions.animateEmojis
|
|
||||||
)
|
|
||||||
setupActionListener(
|
|
||||||
notificationActionListener,
|
|
||||||
viewData.report.targetAccount.id,
|
|
||||||
viewData.account.id,
|
|
||||||
viewData.report.id
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun setupWithReport(
|
|
||||||
reporter: TimelineAccount,
|
|
||||||
report: Report,
|
|
||||||
animateAvatar: Boolean,
|
|
||||||
animateEmojis: Boolean
|
|
||||||
) {
|
|
||||||
val reporterName = reporter.name.unicodeWrap().emojify(
|
|
||||||
reporter.emojis,
|
|
||||||
binding.root,
|
|
||||||
animateEmojis
|
|
||||||
)
|
|
||||||
val reporteeName = report.targetAccount.name.unicodeWrap().emojify(
|
|
||||||
report.targetAccount.emojis,
|
|
||||||
itemView,
|
|
||||||
animateEmojis
|
|
||||||
)
|
|
||||||
val icon = ContextCompat.getDrawable(binding.root.context, R.drawable.ic_flag_24dp)
|
|
||||||
|
|
||||||
binding.notificationTopText.setCompoundDrawablesWithIntrinsicBounds(icon, null, null, null)
|
binding.notificationTopText.setCompoundDrawablesWithIntrinsicBounds(icon, null, null, null)
|
||||||
binding.notificationTopText.text = itemView.context.getString(
|
binding.notificationTopText.text = itemView.context.getString(R.string.notification_header_report_format, reporterName, reporteeName)
|
||||||
R.string.notification_header_report_format,
|
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)
|
||||||
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)
|
binding.notificationCategory.text = getTranslatedCategory(itemView.context, report.category)
|
||||||
|
|
||||||
// Fancy avatar inset
|
// Fancy avatar inset
|
||||||
@ -100,22 +52,17 @@ class ReportNotificationViewHolder(
|
|||||||
report.targetAccount.avatar,
|
report.targetAccount.avatar,
|
||||||
binding.notificationReporteeAvatar,
|
binding.notificationReporteeAvatar,
|
||||||
itemView.context.resources.getDimensionPixelSize(R.dimen.avatar_radius_36dp),
|
itemView.context.resources.getDimensionPixelSize(R.dimen.avatar_radius_36dp),
|
||||||
animateAvatar
|
animateAvatar,
|
||||||
)
|
)
|
||||||
loadAvatar(
|
loadAvatar(
|
||||||
reporter.avatar,
|
reporter.avatar,
|
||||||
binding.notificationReporterAvatar,
|
binding.notificationReporterAvatar,
|
||||||
itemView.context.resources.getDimensionPixelSize(R.dimen.avatar_radius_24dp),
|
itemView.context.resources.getDimensionPixelSize(R.dimen.avatar_radius_24dp),
|
||||||
animateAvatar
|
animateAvatar,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun setupActionListener(
|
fun setupActionListener(listener: NotificationActionListener, reporteeId: String, reporterId: String, reportId: String) {
|
||||||
listener: NotificationActionListener,
|
|
||||||
reporteeId: String,
|
|
||||||
reporterId: String,
|
|
||||||
reportId: String
|
|
||||||
) {
|
|
||||||
binding.notificationReporteeAvatar.setOnClickListener {
|
binding.notificationReporteeAvatar.setOnClickListener {
|
||||||
val position = bindingAdapterPosition
|
val position = bindingAdapterPosition
|
||||||
if (position != RecyclerView.NO_POSITION) {
|
if (position != RecyclerView.NO_POSITION) {
|
||||||
|
@ -1,5 +1,7 @@
|
|||||||
package com.keylesspalace.tusky.appstore
|
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.Flow
|
||||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
@ -13,7 +15,12 @@ class EventHub @Inject constructor() {
|
|||||||
private val sharedEventFlow: MutableSharedFlow<Event> = MutableSharedFlow()
|
private val sharedEventFlow: MutableSharedFlow<Event> = MutableSharedFlow()
|
||||||
val events: Flow<Event> = sharedEventFlow
|
val events: Flow<Event> = sharedEventFlow
|
||||||
|
|
||||||
|
// TODO remove this old stuff as soon as NotificationsFragment is Kotlin
|
||||||
|
private val eventsSubject = PublishSubject.create<Event>()
|
||||||
|
val eventsObservable: Observable<Event> = eventsSubject
|
||||||
|
|
||||||
suspend fun dispatch(event: Event) {
|
suspend fun dispatch(event: Event) {
|
||||||
sharedEventFlow.emit(event)
|
sharedEventFlow.emit(event)
|
||||||
|
eventsSubject.onNext(event)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -44,7 +44,6 @@ class FollowRequestsAdapter(
|
|||||||
)
|
)
|
||||||
return FollowRequestViewHolder(
|
return FollowRequestViewHolder(
|
||||||
binding,
|
binding,
|
||||||
accountActionListener,
|
|
||||||
linkListener,
|
linkListener,
|
||||||
showHeader = false
|
showHeader = false
|
||||||
)
|
)
|
||||||
|
@ -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 <http://www.gnu.org/licenses>.
|
|
||||||
*/
|
|
||||||
|
|
||||||
package com.keylesspalace.tusky.components.notifications
|
|
||||||
|
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
|
||||||
import com.keylesspalace.tusky.R
|
|
||||||
import com.keylesspalace.tusky.databinding.ItemFollowBinding
|
|
||||||
import com.keylesspalace.tusky.entity.Notification
|
|
||||||
import com.keylesspalace.tusky.entity.TimelineAccount
|
|
||||||
import com.keylesspalace.tusky.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) }
|
|
||||||
}
|
|
||||||
}
|
|
@ -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 <http://www.gnu.org/licenses>.
|
|
||||||
*/
|
|
||||||
|
|
||||||
package com.keylesspalace.tusky.components.notifications
|
|
||||||
|
|
||||||
import android.app.Dialog
|
|
||||||
import android.content.DialogInterface
|
|
||||||
import android.os.Bundle
|
|
||||||
import android.util.Log
|
|
||||||
import android.view.LayoutInflater
|
|
||||||
import android.view.Menu
|
|
||||||
import android.view.MenuInflater
|
|
||||||
import android.view.MenuItem
|
|
||||||
import android.view.View
|
|
||||||
import android.view.ViewGroup
|
|
||||||
import androidx.appcompat.app.AlertDialog
|
|
||||||
import androidx.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<NotificationActionSuccess>()
|
|
||||||
.collect {
|
|
||||||
Snackbar.make(
|
|
||||||
(activity as ActionButtonActivity).actionButton ?: binding.root,
|
|
||||||
getString(it.msg),
|
|
||||||
Snackbar.LENGTH_SHORT
|
|
||||||
).show()
|
|
||||||
|
|
||||||
when (it) {
|
|
||||||
// The follow request is no longer valid, refresh the adapter to
|
|
||||||
// remove it.
|
|
||||||
is NotificationActionSuccess.AcceptFollowRequest,
|
|
||||||
is NotificationActionSuccess.RejectFollowRequest -> adapter.refresh()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update adapter data when status actions are successful, and re-bind to update
|
|
||||||
// the UI.
|
|
||||||
launch {
|
|
||||||
viewModel.uiSuccess
|
|
||||||
.filterIsInstance<StatusActionSuccess>()
|
|
||||||
.collect {
|
|
||||||
val indexedViewData = adapter.snapshot()
|
|
||||||
.withIndex()
|
|
||||||
.firstOrNull { notificationViewData ->
|
|
||||||
notificationViewData.value?.statusViewData?.status?.id ==
|
|
||||||
it.action.statusViewData.id
|
|
||||||
} ?: return@collect
|
|
||||||
|
|
||||||
val statusViewData =
|
|
||||||
indexedViewData.value?.statusViewData ?: return@collect
|
|
||||||
|
|
||||||
val status = when (it) {
|
|
||||||
is StatusActionSuccess.Bookmark ->
|
|
||||||
statusViewData.status.copy(bookmarked = it.action.state)
|
|
||||||
is StatusActionSuccess.Favourite ->
|
|
||||||
statusViewData.status.copy(favourited = it.action.state)
|
|
||||||
is StatusActionSuccess.Reblog ->
|
|
||||||
statusViewData.status.copy(reblogged = it.action.state)
|
|
||||||
is StatusActionSuccess.VoteInPoll ->
|
|
||||||
statusViewData.status.copy(
|
|
||||||
poll = it.action.poll.votedCopy(it.action.choices)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
indexedViewData.value?.statusViewData = statusViewData.copy(
|
|
||||||
status = status
|
|
||||||
)
|
|
||||||
|
|
||||||
adapter.notifyItemChanged(indexedViewData.index)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Refresh adapter on mutes and blocks
|
|
||||||
launch {
|
|
||||||
viewModel.uiSuccess.collectLatest {
|
|
||||||
when (it) {
|
|
||||||
is UiSuccess.Block, is UiSuccess.Mute, is UiSuccess.MuteConversation ->
|
|
||||||
adapter.refresh()
|
|
||||||
else -> { /* nothing to do */
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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<Int>) {
|
|
||||||
val statusViewData = adapter.peek(position)?.statusViewData ?: return
|
|
||||||
val poll = statusViewData.status.poll ?: return
|
|
||||||
viewModel.accept(StatusAction.VoteInPoll(poll, choices, statusViewData))
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onMore(view: View, position: Int) {
|
|
||||||
val status = adapter.peek(position)?.statusViewData?.status ?: return
|
|
||||||
super.more(status, view, position)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onViewMedia(position: Int, attachmentIndex: Int, view: View?) {
|
|
||||||
val status = adapter.peek(position)?.statusViewData?.status ?: return
|
|
||||||
super.viewMedia(
|
|
||||||
attachmentIndex,
|
|
||||||
list(status, 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<NotificationViewData> =
|
|
||||||
object : DiffUtil.ItemCallback<NotificationViewData>() {
|
|
||||||
override fun areItemsTheSame(
|
|
||||||
oldItem: NotificationViewData,
|
|
||||||
newItem: NotificationViewData
|
|
||||||
): Boolean {
|
|
||||||
return oldItem.id == newItem.id
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun areContentsTheSame(
|
|
||||||
oldItem: NotificationViewData,
|
|
||||||
newItem: NotificationViewData
|
|
||||||
): Boolean {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun getChangePayload(
|
|
||||||
oldItem: NotificationViewData,
|
|
||||||
newItem: NotificationViewData
|
|
||||||
): Any? {
|
|
||||||
return if (oldItem == newItem) {
|
|
||||||
// If items are equal - update timestamp only
|
|
||||||
listOf(StatusBaseViewHolder.Key.KEY_CREATED)
|
|
||||||
} else {
|
|
||||||
// If items are different - update a whole view holder
|
|
||||||
null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class FilterDialogFragment(
|
|
||||||
private val activeFilter: Set<Notification.Type>,
|
|
||||||
private val listener: ((filter: Set<Notification.Type>) -> Unit)
|
|
||||||
) : DialogFragment() {
|
|
||||||
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
|
|
||||||
val context = requireContext()
|
|
||||||
|
|
||||||
val items = Notification.Type.visibleTypes.map { getString(it.uiString) }.toTypedArray()
|
|
||||||
val checkedItems = Notification.Type.visibleTypes.map {
|
|
||||||
!activeFilter.contains(it)
|
|
||||||
}.toBooleanArray()
|
|
||||||
|
|
||||||
val builder = AlertDialog.Builder(context)
|
|
||||||
.setTitle(R.string.notifications_apply_filter)
|
|
||||||
.setMultiChoiceItems(items, checkedItems) { _, which, isChecked ->
|
|
||||||
checkedItems[which] = isChecked
|
|
||||||
}
|
|
||||||
.setPositiveButton(android.R.string.ok) { _, _ ->
|
|
||||||
val excludes: MutableSet<Notification.Type> = HashSet()
|
|
||||||
for (i in Notification.Type.visibleTypes.indices) {
|
|
||||||
if (!checkedItems[i]) excludes.add(Notification.Type.visibleTypes[i])
|
|
||||||
}
|
|
||||||
listener(excludes)
|
|
||||||
}
|
|
||||||
.setNegativeButton(android.R.string.cancel) { _, _ -> }
|
|
||||||
return builder.create()
|
|
||||||
}
|
|
||||||
}
|
|
@ -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 <http://www.gnu.org/licenses>.
|
|
||||||
*/
|
|
||||||
|
|
||||||
package com.keylesspalace.tusky.components.notifications
|
|
||||||
|
|
||||||
import android.view.LayoutInflater
|
|
||||||
import android.view.ViewGroup
|
|
||||||
import androidx.paging.PagingDataAdapter
|
|
||||||
import androidx.recyclerview.widget.DiffUtil
|
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
|
||||||
import com.keylesspalace.tusky.adapter.FollowRequestViewHolder
|
|
||||||
import com.keylesspalace.tusky.adapter.ReportNotificationViewHolder
|
|
||||||
import com.keylesspalace.tusky.databinding.ItemFollowBinding
|
|
||||||
import com.keylesspalace.tusky.databinding.ItemFollowRequestBinding
|
|
||||||
import com.keylesspalace.tusky.databinding.ItemReportNotificationBinding
|
|
||||||
import com.keylesspalace.tusky.databinding.ItemStatusBinding
|
|
||||||
import com.keylesspalace.tusky.databinding.ItemStatusNotificationBinding
|
|
||||||
import com.keylesspalace.tusky.databinding.SimpleListItem1Binding
|
|
||||||
import com.keylesspalace.tusky.entity.Notification
|
|
||||||
import com.keylesspalace.tusky.entity.Status
|
|
||||||
import com.keylesspalace.tusky.interfaces.AccountActionListener
|
|
||||||
import com.keylesspalace.tusky.interfaces.StatusActionListener
|
|
||||||
import com.keylesspalace.tusky.util.AbsoluteTimeFormatter
|
|
||||||
import com.keylesspalace.tusky.util.StatusDisplayOptions
|
|
||||||
import com.keylesspalace.tusky.viewdata.NotificationViewData
|
|
||||||
|
|
||||||
/** How to present the notification in the UI */
|
|
||||||
enum class NotificationViewKind {
|
|
||||||
/** View as the original status */
|
|
||||||
STATUS,
|
|
||||||
|
|
||||||
/** View as the original status, with the interaction type above */
|
|
||||||
NOTIFICATION,
|
|
||||||
FOLLOW,
|
|
||||||
FOLLOW_REQUEST,
|
|
||||||
REPORT,
|
|
||||||
UNKNOWN;
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
fun from(kind: Notification.Type?): NotificationViewKind {
|
|
||||||
return when (kind) {
|
|
||||||
Notification.Type.MENTION,
|
|
||||||
Notification.Type.POLL,
|
|
||||||
Notification.Type.UNKNOWN -> STATUS
|
|
||||||
Notification.Type.FAVOURITE,
|
|
||||||
Notification.Type.REBLOG,
|
|
||||||
Notification.Type.STATUS,
|
|
||||||
Notification.Type.UPDATE -> NOTIFICATION
|
|
||||||
Notification.Type.FOLLOW,
|
|
||||||
Notification.Type.SIGN_UP -> FOLLOW
|
|
||||||
Notification.Type.FOLLOW_REQUEST -> FOLLOW_REQUEST
|
|
||||||
Notification.Type.REPORT -> REPORT
|
|
||||||
null -> UNKNOWN
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
interface NotificationActionListener {
|
|
||||||
fun onViewAccount(id: String)
|
|
||||||
fun onViewThreadForStatus(status: Status)
|
|
||||||
fun onViewReport(reportId: String)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Called when the status has a content warning and the visibility of the content behind
|
|
||||||
* the warning is being changed.
|
|
||||||
*
|
|
||||||
* @param expanded the desired state of the content behind the content warning
|
|
||||||
* @param position the adapter position of the view
|
|
||||||
*
|
|
||||||
*/
|
|
||||||
fun onExpandedChange(expanded: Boolean, position: Int)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Called when the status [android.widget.ToggleButton] responsible for collapsing long
|
|
||||||
* status content is interacted with.
|
|
||||||
*
|
|
||||||
* @param isCollapsed Whether the status content is shown in a collapsed state or fully.
|
|
||||||
* @param position The position of the status in the list.
|
|
||||||
*/
|
|
||||||
fun onNotificationContentCollapsedChange(isCollapsed: Boolean, position: Int)
|
|
||||||
}
|
|
||||||
|
|
||||||
class NotificationsPagingAdapter(
|
|
||||||
diffCallback: DiffUtil.ItemCallback<NotificationViewData>,
|
|
||||||
/** ID of the the account that notifications are being displayed for */
|
|
||||||
private val accountId: String,
|
|
||||||
private val statusActionListener: StatusActionListener,
|
|
||||||
private val notificationActionListener: NotificationActionListener,
|
|
||||||
private val accountActionListener: AccountActionListener,
|
|
||||||
var statusDisplayOptions: StatusDisplayOptions
|
|
||||||
) : PagingDataAdapter<NotificationViewData, RecyclerView.ViewHolder>(diffCallback) {
|
|
||||||
|
|
||||||
private val absoluteTimeFormatter = AbsoluteTimeFormatter()
|
|
||||||
|
|
||||||
/** View holders in this adapter must implement this interface */
|
|
||||||
interface ViewHolder {
|
|
||||||
/** Bind the data from the notification and payloads to the view */
|
|
||||||
fun bind(
|
|
||||||
viewData: NotificationViewData,
|
|
||||||
payloads: List<*>?,
|
|
||||||
statusDisplayOptions: StatusDisplayOptions
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
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<Any>
|
|
||||||
) {
|
|
||||||
bindViewHolder(holder, position, payloads)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun bindViewHolder(
|
|
||||||
holder: RecyclerView.ViewHolder,
|
|
||||||
position: Int,
|
|
||||||
payloads: List<*>?
|
|
||||||
) {
|
|
||||||
getItem(position)?.let { (holder as ViewHolder).bind(it, payloads, statusDisplayOptions) }
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Notification view holder to use if no other type is appropriate. Should never normally
|
|
||||||
* be used, but is useful when migrating code.
|
|
||||||
*/
|
|
||||||
private class FallbackNotificationViewHolder(
|
|
||||||
val binding: SimpleListItem1Binding
|
|
||||||
) : ViewHolder, RecyclerView.ViewHolder(binding.root) {
|
|
||||||
override fun bind(
|
|
||||||
viewData: NotificationViewData,
|
|
||||||
payloads: List<*>?,
|
|
||||||
statusDisplayOptions: StatusDisplayOptions
|
|
||||||
) {
|
|
||||||
binding.text1.text = viewData.statusViewData?.content
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -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 <http://www.gnu.org/licenses>.
|
|
||||||
*/
|
|
||||||
|
|
||||||
package com.keylesspalace.tusky.components.notifications
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import android.graphics.PorterDuff
|
|
||||||
import android.graphics.Typeface
|
|
||||||
import android.graphics.drawable.Drawable
|
|
||||||
import android.text.InputFilter
|
|
||||||
import android.text.SpannableStringBuilder
|
|
||||||
import android.text.Spanned
|
|
||||||
import android.text.TextUtils
|
|
||||||
import android.text.format.DateUtils
|
|
||||||
import android.text.style.StyleSpan
|
|
||||||
import android.view.View
|
|
||||||
import androidx.annotation.ColorRes
|
|
||||||
import androidx.annotation.DrawableRes
|
|
||||||
import androidx.core.content.ContextCompat
|
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
|
||||||
import at.connyduck.sparkbutton.helpers.Utils
|
|
||||||
import com.bumptech.glide.Glide
|
|
||||||
import com.keylesspalace.tusky.R
|
|
||||||
import com.keylesspalace.tusky.adapter.StatusBaseViewHolder
|
|
||||||
import com.keylesspalace.tusky.databinding.ItemStatusNotificationBinding
|
|
||||||
import com.keylesspalace.tusky.entity.Emoji
|
|
||||||
import com.keylesspalace.tusky.entity.Notification
|
|
||||||
import com.keylesspalace.tusky.interfaces.LinkListener
|
|
||||||
import com.keylesspalace.tusky.interfaces.StatusActionListener
|
|
||||||
import com.keylesspalace.tusky.util.AbsoluteTimeFormatter
|
|
||||||
import com.keylesspalace.tusky.util.SmartLengthInputFilter
|
|
||||||
import com.keylesspalace.tusky.util.StatusDisplayOptions
|
|
||||||
import com.keylesspalace.tusky.util.emojify
|
|
||||||
import com.keylesspalace.tusky.util.getRelativeTimeSpanString
|
|
||||||
import com.keylesspalace.tusky.util.loadAvatar
|
|
||||||
import com.keylesspalace.tusky.util.setClickableText
|
|
||||||
import com.keylesspalace.tusky.util.unicodeWrap
|
|
||||||
import com.keylesspalace.tusky.viewdata.NotificationViewData
|
|
||||||
import com.keylesspalace.tusky.viewdata.StatusViewData
|
|
||||||
import java.util.Date
|
|
||||||
|
|
||||||
/**
|
|
||||||
* View holder for a status with an activity to be notified about (posted, boosted,
|
|
||||||
* favourited, or edited, per [NotificationViewKind.from]).
|
|
||||||
*
|
|
||||||
* Shows a line with the activity, and who initiated the activity. Clicking this should
|
|
||||||
* go to the profile page for the initiator.
|
|
||||||
*
|
|
||||||
* Displays the original status below that. Clicking this should go to the original
|
|
||||||
* status in context.
|
|
||||||
*/
|
|
||||||
internal class StatusNotificationViewHolder(
|
|
||||||
private val binding: ItemStatusNotificationBinding,
|
|
||||||
private val statusActionListener: StatusActionListener,
|
|
||||||
private val notificationActionListener: NotificationActionListener,
|
|
||||||
private val absoluteTimeFormatter: AbsoluteTimeFormatter
|
|
||||||
) : NotificationsPagingAdapter.ViewHolder, RecyclerView.ViewHolder(binding.root) {
|
|
||||||
private val avatarRadius48dp = itemView.context.resources.getDimensionPixelSize(
|
|
||||||
R.dimen.avatar_radius_48dp
|
|
||||||
)
|
|
||||||
private val avatarRadius36dp = itemView.context.resources.getDimensionPixelSize(
|
|
||||||
R.dimen.avatar_radius_36dp
|
|
||||||
)
|
|
||||||
private val avatarRadius24dp = itemView.context.resources.getDimensionPixelSize(
|
|
||||||
R.dimen.avatar_radius_24dp
|
|
||||||
)
|
|
||||||
|
|
||||||
override fun bind(
|
|
||||||
viewData: NotificationViewData,
|
|
||||||
payloads: List<*>?,
|
|
||||||
statusDisplayOptions: StatusDisplayOptions
|
|
||||||
) {
|
|
||||||
val statusViewData = viewData.statusViewData
|
|
||||||
if (payloads.isNullOrEmpty()) {
|
|
||||||
// Hide null statuses. Shouldn't happen according to the spec, but some servers
|
|
||||||
// have been seen to do this (https://github.com/tuskyapp/Tusky/issues/2252)
|
|
||||||
if (statusViewData == null) {
|
|
||||||
showNotificationContent(false)
|
|
||||||
} else {
|
|
||||||
showNotificationContent(true)
|
|
||||||
val (_, _, account, _, _, _, _, createdAt) = statusViewData.actionable
|
|
||||||
setDisplayName(account.name, account.emojis, statusDisplayOptions.animateEmojis)
|
|
||||||
setUsername(account.username)
|
|
||||||
setCreatedAt(createdAt, statusDisplayOptions.useAbsoluteTime)
|
|
||||||
if (viewData.type == Notification.Type.STATUS ||
|
|
||||||
viewData.type == Notification.Type.UPDATE
|
|
||||||
) {
|
|
||||||
setAvatar(
|
|
||||||
account.avatar,
|
|
||||||
account.bot,
|
|
||||||
statusDisplayOptions.animateAvatars,
|
|
||||||
statusDisplayOptions.showBotOverlay
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
setAvatars(
|
|
||||||
account.avatar,
|
|
||||||
viewData.account.avatar,
|
|
||||||
statusDisplayOptions.animateAvatars
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
binding.notificationContainer.setOnClickListener {
|
|
||||||
notificationActionListener.onViewThreadForStatus(statusViewData.status)
|
|
||||||
}
|
|
||||||
binding.notificationContent.setOnClickListener {
|
|
||||||
notificationActionListener.onViewThreadForStatus(statusViewData.status)
|
|
||||||
}
|
|
||||||
binding.notificationTopText.setOnClickListener {
|
|
||||||
notificationActionListener.onViewAccount(viewData.account.id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
setMessage(viewData, statusActionListener, statusDisplayOptions.animateEmojis)
|
|
||||||
} else {
|
|
||||||
for (item in payloads) {
|
|
||||||
if (StatusBaseViewHolder.Key.KEY_CREATED == item && statusViewData != null) {
|
|
||||||
setCreatedAt(
|
|
||||||
statusViewData.status.actionableStatus.createdAt,
|
|
||||||
statusDisplayOptions.useAbsoluteTime
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun showNotificationContent(show: Boolean) {
|
|
||||||
binding.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<Emoji>?, animateEmojis: Boolean) {
|
|
||||||
val emojifiedName = name.emojify(emojis, binding.statusDisplayName, animateEmojis)
|
|
||||||
binding.statusDisplayName.text = emojifiedName
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun setUsername(name: String) {
|
|
||||||
val context = binding.statusUsername.context
|
|
||||||
val format = context.getString(R.string.post_username_format)
|
|
||||||
val usernameText = String.format(format, name)
|
|
||||||
binding.statusUsername.text = usernameText
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun setCreatedAt(createdAt: Date?, useAbsoluteTime: Boolean) {
|
|
||||||
if (useAbsoluteTime) {
|
|
||||||
binding.statusMetaInfo.text = absoluteTimeFormatter.format(createdAt, true)
|
|
||||||
} else {
|
|
||||||
// This is the visible timestampInfo.
|
|
||||||
val readout: String
|
|
||||||
/* This one is for screen-readers. Frequently, they would mispronounce timestamps like "17m"
|
|
||||||
* as 17 meters instead of minutes. */
|
|
||||||
val readoutAloud: CharSequence
|
|
||||||
if (createdAt != null) {
|
|
||||||
val then = createdAt.time
|
|
||||||
val now = Date().time
|
|
||||||
readout = getRelativeTimeSpanString(binding.statusMetaInfo.context, then, now)
|
|
||||||
readoutAloud = DateUtils.getRelativeTimeSpanString(
|
|
||||||
then,
|
|
||||||
now,
|
|
||||||
DateUtils.SECOND_IN_MILLIS,
|
|
||||||
DateUtils.FORMAT_ABBREV_RELATIVE
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
// unknown minutes~
|
|
||||||
readout = "?m"
|
|
||||||
readoutAloud = "? minutes"
|
|
||||||
}
|
|
||||||
binding.statusMetaInfo.text = readout
|
|
||||||
binding.statusMetaInfo.contentDescription = readoutAloud
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun getIconWithColor(
|
|
||||||
context: Context,
|
|
||||||
@DrawableRes drawable: Int,
|
|
||||||
@ColorRes color: Int
|
|
||||||
): Drawable? {
|
|
||||||
val icon = ContextCompat.getDrawable(context, drawable)
|
|
||||||
icon?.setColorFilter(context.getColor(color), PorterDuff.Mode.SRC_ATOP)
|
|
||||||
return icon
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun setAvatar(statusAvatarUrl: String?, isBot: Boolean, animateAvatars: Boolean, showBotOverlay: Boolean) {
|
|
||||||
binding.notificationStatusAvatar.setPaddingRelative(0, 0, 0, 0)
|
|
||||||
loadAvatar(
|
|
||||||
statusAvatarUrl,
|
|
||||||
binding.notificationStatusAvatar,
|
|
||||||
avatarRadius48dp,
|
|
||||||
animateAvatars
|
|
||||||
)
|
|
||||||
if (showBotOverlay && isBot) {
|
|
||||||
binding.notificationNotificationAvatar.visibility = View.VISIBLE
|
|
||||||
Glide.with(binding.notificationNotificationAvatar)
|
|
||||||
.load(R.drawable.bot_badge)
|
|
||||||
.into(binding.notificationNotificationAvatar)
|
|
||||||
} else {
|
|
||||||
binding.notificationNotificationAvatar.visibility = View.GONE
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun setAvatars(statusAvatarUrl: String?, notificationAvatarUrl: String?, animateAvatars: Boolean) {
|
|
||||||
val padding = Utils.dpToPx(binding.notificationStatusAvatar.context, 12)
|
|
||||||
binding.notificationStatusAvatar.setPaddingRelative(0, 0, padding, padding)
|
|
||||||
loadAvatar(
|
|
||||||
statusAvatarUrl,
|
|
||||||
binding.notificationStatusAvatar,
|
|
||||||
avatarRadius36dp,
|
|
||||||
animateAvatars
|
|
||||||
)
|
|
||||||
binding.notificationNotificationAvatar.visibility = View.VISIBLE
|
|
||||||
loadAvatar(
|
|
||||||
notificationAvatarUrl,
|
|
||||||
binding.notificationNotificationAvatar,
|
|
||||||
avatarRadius24dp,
|
|
||||||
animateAvatars
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setMessage(
|
|
||||||
notificationViewData: NotificationViewData,
|
|
||||||
listener: LinkListener,
|
|
||||||
animateEmojis: Boolean
|
|
||||||
) {
|
|
||||||
val statusViewData = notificationViewData.statusViewData
|
|
||||||
val displayName = notificationViewData.account.name.unicodeWrap()
|
|
||||||
val type = notificationViewData.type
|
|
||||||
val context = binding.notificationTopText.context
|
|
||||||
val format: String
|
|
||||||
val icon: Drawable?
|
|
||||||
when (type) {
|
|
||||||
Notification.Type.FAVOURITE -> {
|
|
||||||
icon = getIconWithColor(context, R.drawable.ic_star_24dp, R.color.tusky_orange)
|
|
||||||
format = context.getString(R.string.notification_favourite_format)
|
|
||||||
}
|
|
||||||
Notification.Type.REBLOG -> {
|
|
||||||
icon = getIconWithColor(context, R.drawable.ic_repeat_24dp, R.color.tusky_blue)
|
|
||||||
format = context.getString(R.string.notification_reblog_format)
|
|
||||||
}
|
|
||||||
Notification.Type.STATUS -> {
|
|
||||||
icon = getIconWithColor(context, R.drawable.ic_home_24dp, R.color.tusky_blue)
|
|
||||||
format = context.getString(R.string.notification_subscription_format)
|
|
||||||
}
|
|
||||||
Notification.Type.UPDATE -> {
|
|
||||||
icon = getIconWithColor(context, R.drawable.ic_edit_24dp, R.color.tusky_blue)
|
|
||||||
format = context.getString(R.string.notification_update_format)
|
|
||||||
}
|
|
||||||
else -> {
|
|
||||||
icon = getIconWithColor(context, R.drawable.ic_star_24dp, R.color.tusky_orange)
|
|
||||||
format = context.getString(R.string.notification_favourite_format)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
binding.notificationTopText.setCompoundDrawablesWithIntrinsicBounds(
|
|
||||||
icon,
|
|
||||||
null,
|
|
||||||
null,
|
|
||||||
null
|
|
||||||
)
|
|
||||||
val wholeMessage = String.format(format, displayName)
|
|
||||||
val str = SpannableStringBuilder(wholeMessage)
|
|
||||||
val displayNameIndex = format.indexOf("%s")
|
|
||||||
str.setSpan(
|
|
||||||
StyleSpan(Typeface.BOLD),
|
|
||||||
displayNameIndex,
|
|
||||||
displayNameIndex + displayName.length,
|
|
||||||
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
|
|
||||||
)
|
|
||||||
val emojifiedText = str.emojify(
|
|
||||||
notificationViewData.account.emojis,
|
|
||||||
binding.notificationTopText,
|
|
||||||
animateEmojis
|
|
||||||
)
|
|
||||||
binding.notificationTopText.text = emojifiedText
|
|
||||||
if (statusViewData != null) {
|
|
||||||
val hasSpoiler = !TextUtils.isEmpty(statusViewData.status.spoilerText)
|
|
||||||
binding.notificationContentWarningDescription.visibility =
|
|
||||||
if (hasSpoiler) View.VISIBLE else View.GONE
|
|
||||||
binding.notificationContentWarningButton.visibility =
|
|
||||||
if (hasSpoiler) View.VISIBLE else View.GONE
|
|
||||||
if (statusViewData.isExpanded) {
|
|
||||||
binding.notificationContentWarningButton.setText(
|
|
||||||
R.string.post_content_warning_show_less
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
binding.notificationContentWarningButton.setText(
|
|
||||||
R.string.post_content_warning_show_more
|
|
||||||
)
|
|
||||||
}
|
|
||||||
binding.notificationContentWarningButton.setOnClickListener {
|
|
||||||
if (bindingAdapterPosition != RecyclerView.NO_POSITION) {
|
|
||||||
notificationActionListener.onExpandedChange(
|
|
||||||
!statusViewData.isExpanded,
|
|
||||||
bindingAdapterPosition
|
|
||||||
)
|
|
||||||
}
|
|
||||||
binding.notificationContent.visibility =
|
|
||||||
if (statusViewData.isExpanded) View.GONE else View.VISIBLE
|
|
||||||
}
|
|
||||||
setupContentAndSpoiler(listener, statusViewData, animateEmojis)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun setupContentAndSpoiler(
|
|
||||||
listener: LinkListener,
|
|
||||||
statusViewData: StatusViewData.Concrete,
|
|
||||||
animateEmojis: Boolean
|
|
||||||
) {
|
|
||||||
val shouldShowContentIfSpoiler = statusViewData.isExpanded
|
|
||||||
val hasSpoiler = !TextUtils.isEmpty(statusViewData.status.spoilerText)
|
|
||||||
if (!shouldShowContentIfSpoiler && hasSpoiler) {
|
|
||||||
binding.notificationContent.visibility = View.GONE
|
|
||||||
} else {
|
|
||||||
binding.notificationContent.visibility = View.VISIBLE
|
|
||||||
}
|
|
||||||
val content = statusViewData.content
|
|
||||||
val emojis = statusViewData.actionable.emojis
|
|
||||||
if (statusViewData.isCollapsible && (statusViewData.isExpanded || !hasSpoiler)) {
|
|
||||||
binding.buttonToggleNotificationContent.setOnClickListener {
|
|
||||||
val position = bindingAdapterPosition
|
|
||||||
if (position != RecyclerView.NO_POSITION) {
|
|
||||||
notificationActionListener.onNotificationContentCollapsedChange(
|
|
||||||
!statusViewData.isCollapsed,
|
|
||||||
position
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
binding.buttonToggleNotificationContent.visibility = View.VISIBLE
|
|
||||||
if (statusViewData.isCollapsed) {
|
|
||||||
binding.buttonToggleNotificationContent.setText(
|
|
||||||
R.string.post_content_warning_show_more
|
|
||||||
)
|
|
||||||
binding.notificationContent.filters = COLLAPSE_INPUT_FILTER
|
|
||||||
} else {
|
|
||||||
binding.buttonToggleNotificationContent.setText(
|
|
||||||
R.string.post_content_warning_show_less
|
|
||||||
)
|
|
||||||
binding.notificationContent.filters = NO_INPUT_FILTER
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
binding.buttonToggleNotificationContent.visibility = View.GONE
|
|
||||||
binding.notificationContent.filters = NO_INPUT_FILTER
|
|
||||||
}
|
|
||||||
val emojifiedText =
|
|
||||||
content.emojify(
|
|
||||||
emojis,
|
|
||||||
binding.notificationContent,
|
|
||||||
animateEmojis
|
|
||||||
)
|
|
||||||
setClickableText(
|
|
||||||
binding.notificationContent,
|
|
||||||
emojifiedText,
|
|
||||||
statusViewData.actionable.mentions,
|
|
||||||
statusViewData.actionable.tags,
|
|
||||||
listener
|
|
||||||
)
|
|
||||||
val emojifiedContentWarning: CharSequence = statusViewData.spoilerText.emojify(
|
|
||||||
statusViewData.actionable.emojis,
|
|
||||||
binding.notificationContentWarningDescription,
|
|
||||||
animateEmojis
|
|
||||||
)
|
|
||||||
binding.notificationContentWarningDescription.text = emojifiedContentWarning
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
private val COLLAPSE_INPUT_FILTER = arrayOf<InputFilter>(SmartLengthInputFilter)
|
|
||||||
private val NO_INPUT_FILTER = arrayOfNulls<InputFilter>(0)
|
|
||||||
}
|
|
||||||
}
|
|
@ -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 <http://www.gnu.org/licenses>.
|
|
||||||
*/
|
|
||||||
|
|
||||||
package com.keylesspalace.tusky.components.notifications
|
|
||||||
|
|
||||||
import com.keylesspalace.tusky.adapter.StatusViewHolder
|
|
||||||
import com.keylesspalace.tusky.databinding.ItemStatusBinding
|
|
||||||
import com.keylesspalace.tusky.entity.Notification
|
|
||||||
import com.keylesspalace.tusky.interfaces.StatusActionListener
|
|
||||||
import com.keylesspalace.tusky.util.StatusDisplayOptions
|
|
||||||
import com.keylesspalace.tusky.viewdata.NotificationViewData
|
|
||||||
|
|
||||||
internal class StatusViewHolder(
|
|
||||||
binding: ItemStatusBinding,
|
|
||||||
private val statusActionListener: StatusActionListener,
|
|
||||||
private val accountId: String
|
|
||||||
) : NotificationsPagingAdapter.ViewHolder, StatusViewHolder(binding.root) {
|
|
||||||
|
|
||||||
override fun bind(
|
|
||||||
viewData: NotificationViewData,
|
|
||||||
payloads: List<*>?,
|
|
||||||
statusDisplayOptions: StatusDisplayOptions
|
|
||||||
) {
|
|
||||||
val statusViewData = viewData.statusViewData
|
|
||||||
if (statusViewData == null) {
|
|
||||||
// Hide null statuses. Shouldn't happen according to the spec, but some servers
|
|
||||||
// have been seen to do this (https://github.com/tuskyapp/Tusky/issues/2252)
|
|
||||||
showStatusContent(false)
|
|
||||||
} else {
|
|
||||||
if (payloads.isNullOrEmpty()) {
|
|
||||||
showStatusContent(true)
|
|
||||||
}
|
|
||||||
setupWithStatus(
|
|
||||||
statusViewData,
|
|
||||||
statusActionListener,
|
|
||||||
statusDisplayOptions,
|
|
||||||
payloads?.firstOrNull()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (viewData.type == Notification.Type.POLL) {
|
|
||||||
setPollInfo(accountId == viewData.account.id)
|
|
||||||
} else {
|
|
||||||
hideStatusInfo()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -21,7 +21,6 @@ import com.keylesspalace.tusky.components.account.media.AccountMediaFragment
|
|||||||
import com.keylesspalace.tusky.components.accountlist.AccountListFragment
|
import com.keylesspalace.tusky.components.accountlist.AccountListFragment
|
||||||
import com.keylesspalace.tusky.components.conversation.ConversationsFragment
|
import com.keylesspalace.tusky.components.conversation.ConversationsFragment
|
||||||
import com.keylesspalace.tusky.components.domainblocks.DomainBlocksFragment
|
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.AccountPreferencesFragment
|
||||||
import com.keylesspalace.tusky.components.preference.NotificationPreferencesFragment
|
import com.keylesspalace.tusky.components.preference.NotificationPreferencesFragment
|
||||||
import com.keylesspalace.tusky.components.preference.PreferencesFragment
|
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.trending.TrendingTagsFragment
|
||||||
import com.keylesspalace.tusky.components.viewthread.ViewThreadFragment
|
import com.keylesspalace.tusky.components.viewthread.ViewThreadFragment
|
||||||
import com.keylesspalace.tusky.components.viewthread.edits.ViewEditsFragment
|
import com.keylesspalace.tusky.components.viewthread.edits.ViewEditsFragment
|
||||||
|
import com.keylesspalace.tusky.fragment.NotificationsFragment
|
||||||
import com.keylesspalace.tusky.fragment.ViewVideoFragment
|
import com.keylesspalace.tusky.fragment.ViewVideoFragment
|
||||||
import dagger.Module
|
import dagger.Module
|
||||||
import dagger.android.ContributesAndroidInjector
|
import dagger.android.ContributesAndroidInjector
|
||||||
|
@ -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
|
// for Pleroma compatibility that uses Mention type
|
||||||
fun rewriteToStatusTypeIfNeeded(accountId: String): Notification {
|
fun rewriteToStatusTypeIfNeeded(accountId: String): Notification {
|
||||||
if (type == Type.MENTION && status != null) {
|
if (type == Type.MENTION && status != null) {
|
||||||
|
File diff suppressed because it is too large
Load Diff
@ -144,6 +144,14 @@ interface MastodonApi {
|
|||||||
@Query("exclude_types[]") excludes: Set<Notification.Type>? = null
|
@Query("exclude_types[]") excludes: Set<Notification.Type>? = null
|
||||||
): Response<List<Notification>>
|
): Response<List<Notification>>
|
||||||
|
|
||||||
|
@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<Notification.Type>?
|
||||||
|
): Single<Response<List<Notification>>>
|
||||||
|
|
||||||
/** Fetch a single notification */
|
/** Fetch a single notification */
|
||||||
@GET("api/v1/notifications/{id}")
|
@GET("api/v1/notifications/{id}")
|
||||||
suspend fun notification(
|
suspend fun notification(
|
||||||
@ -177,6 +185,9 @@ interface MastodonApi {
|
|||||||
@POST("api/v1/notifications/clear")
|
@POST("api/v1/notifications/clear")
|
||||||
suspend fun clearNotifications(): Response<ResponseBody>
|
suspend fun clearNotifications(): Response<ResponseBody>
|
||||||
|
|
||||||
|
@POST("api/v1/notifications/clear")
|
||||||
|
fun clearNotificationsOld(): Single<ResponseBody>
|
||||||
|
|
||||||
@FormUrlEncoded
|
@FormUrlEncoded
|
||||||
@PUT("api/v1/media/{mediaId}")
|
@PUT("api/v1/media/{mediaId}")
|
||||||
suspend fun updateMedia(
|
suspend fun updateMedia(
|
||||||
|
74
app/src/main/java/com/keylesspalace/tusky/util/PairedList.kt
Normal file
74
app/src/main/java/com/keylesspalace/tusky/util/PairedList.kt
Normal file
@ -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<T, V> (private val mapper: Function<T, out V>) : AbstractMutableList<T>() {
|
||||||
|
private val main: MutableList<T> = ArrayList()
|
||||||
|
private val synced: MutableList<V> = ArrayList()
|
||||||
|
|
||||||
|
val pairedCopy: List<V>
|
||||||
|
get() = ArrayList(synced)
|
||||||
|
|
||||||
|
fun getPairedItem(index: Int): V {
|
||||||
|
return synced[index]
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getPairedItemOrNull(index: Int): V? {
|
||||||
|
return synced.getOrNull(index)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setPairedItem(index: Int, element: V) {
|
||||||
|
synced[index] = element
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun get(index: Int): T {
|
||||||
|
return main[index]
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun set(index: Int, element: T): T {
|
||||||
|
synced[index] = mapper.apply(element)
|
||||||
|
return main.set(index, element)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun add(element: T): Boolean {
|
||||||
|
synced.add(mapper.apply(element))
|
||||||
|
return main.add(element)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun add(index: Int, element: T) {
|
||||||
|
synced.add(index, mapper.apply(element))
|
||||||
|
main.add(index, element)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun removeAt(index: Int): T {
|
||||||
|
synced.removeAt(index)
|
||||||
|
return main.removeAt(index)
|
||||||
|
}
|
||||||
|
|
||||||
|
override val size: Int
|
||||||
|
get() = main.size
|
||||||
|
}
|
@ -55,12 +55,13 @@ fun Status.toViewData(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@JvmName("notificationToViewData")
|
||||||
fun Notification.toViewData(
|
fun Notification.toViewData(
|
||||||
isShowingContent: Boolean,
|
isShowingContent: Boolean,
|
||||||
isExpanded: Boolean,
|
isExpanded: Boolean,
|
||||||
isCollapsed: Boolean
|
isCollapsed: Boolean
|
||||||
): NotificationViewData {
|
): NotificationViewData.Concrete {
|
||||||
return NotificationViewData(
|
return NotificationViewData.Concrete(
|
||||||
this.type,
|
this.type,
|
||||||
this.id,
|
this.id,
|
||||||
this.account,
|
this.account,
|
||||||
|
@ -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 <http://www.gnu.org/licenses>. */
|
||||||
|
|
||||||
|
package com.keylesspalace.tusky.viewdata;
|
||||||
|
|
||||||
|
import androidx.annotation.Nullable;
|
||||||
|
|
||||||
|
import com.keylesspalace.tusky.entity.Notification;
|
||||||
|
import com.keylesspalace.tusky.entity.Report;
|
||||||
|
import com.keylesspalace.tusky.entity.TimelineAccount;
|
||||||
|
|
||||||
|
import java.util.Objects;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Created by charlag on 12/07/2017.
|
||||||
|
* <p>
|
||||||
|
* Class to represent data required to display either a notification or a placeholder.
|
||||||
|
* It is either a {@link Placeholder} or a {@link Concrete}.
|
||||||
|
* It is modelled this way because close relationship between placeholder and concrete notification
|
||||||
|
* is fine in this case. Placeholder case is not modelled as a type of notification because
|
||||||
|
* invariants would be violated and because it would model domain incorrectly. It is preferable to
|
||||||
|
* {@link com.keylesspalace.tusky.util.Either} because class hierarchy is cheaper, faster and
|
||||||
|
* more native.
|
||||||
|
*/
|
||||||
|
public abstract class NotificationViewData {
|
||||||
|
private NotificationViewData() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public abstract long getViewDataId();
|
||||||
|
|
||||||
|
public abstract boolean deepEquals(NotificationViewData other);
|
||||||
|
|
||||||
|
public static final class Concrete extends NotificationViewData {
|
||||||
|
private final Notification.Type type;
|
||||||
|
private final String id;
|
||||||
|
private final TimelineAccount account;
|
||||||
|
@Nullable
|
||||||
|
private final StatusViewData.Concrete statusViewData;
|
||||||
|
@Nullable
|
||||||
|
private final Report report;
|
||||||
|
|
||||||
|
public Concrete(Notification.Type type, String id, TimelineAccount account,
|
||||||
|
@Nullable StatusViewData.Concrete statusViewData, @Nullable Report report) {
|
||||||
|
this.type = type;
|
||||||
|
this.id = id;
|
||||||
|
this.account = account;
|
||||||
|
this.statusViewData = statusViewData;
|
||||||
|
this.report = report;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Notification.Type getType() {
|
||||||
|
return type;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getId() {
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public TimelineAccount getAccount() {
|
||||||
|
return account;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Nullable
|
||||||
|
public StatusViewData.Concrete getStatusViewData() {
|
||||||
|
return statusViewData;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Nullable
|
||||||
|
public Report getReport() {
|
||||||
|
return report;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public long getViewDataId() {
|
||||||
|
return id.hashCode();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean deepEquals(NotificationViewData o) {
|
||||||
|
if (this == o) return true;
|
||||||
|
if (o == null || getClass() != o.getClass()) return false;
|
||||||
|
Concrete concrete = (Concrete) o;
|
||||||
|
return type == concrete.type &&
|
||||||
|
Objects.equals(id, concrete.id) &&
|
||||||
|
account.getId().equals(concrete.account.getId()) &&
|
||||||
|
(Objects.equals(statusViewData, concrete.statusViewData)) &&
|
||||||
|
(Objects.equals(report, concrete.report));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int hashCode() {
|
||||||
|
|
||||||
|
return Objects.hash(type, id, account, statusViewData);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Concrete copyWithStatus(@Nullable StatusViewData.Concrete statusViewData) {
|
||||||
|
return new Concrete(type, id, account, statusViewData, report);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static final class Placeholder extends NotificationViewData {
|
||||||
|
private final long id;
|
||||||
|
private final boolean isLoading;
|
||||||
|
|
||||||
|
public Placeholder(long id, boolean isLoading) {
|
||||||
|
this.id = id;
|
||||||
|
this.isLoading = isLoading;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isLoading() {
|
||||||
|
return isLoading;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public long getViewDataId() {
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean deepEquals(NotificationViewData other) {
|
||||||
|
if (!(other instanceof Placeholder)) return false;
|
||||||
|
Placeholder that = (Placeholder) other;
|
||||||
|
return isLoading == that.isLoading && id == that.id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -34,7 +34,7 @@ import com.keylesspalace.tusky.entity.Notification
|
|||||||
import com.keylesspalace.tusky.entity.Report
|
import com.keylesspalace.tusky.entity.Report
|
||||||
import com.keylesspalace.tusky.entity.TimelineAccount
|
import com.keylesspalace.tusky.entity.TimelineAccount
|
||||||
|
|
||||||
data class NotificationViewData(
|
data class NotificationViewDataX(
|
||||||
val type: Notification.Type,
|
val type: Notification.Type,
|
||||||
val id: String,
|
val id: String,
|
||||||
val account: TimelineAccount,
|
val account: TimelineAccount,
|
||||||
|
@ -92,6 +92,21 @@ sealed class StatusViewData {
|
|||||||
this.isCollapsible = shouldTrimStatus(this.content)
|
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 */
|
/** Helper for Java */
|
||||||
fun copyWithCollapsed(isCollapsed: Boolean): Concrete {
|
fun copyWithCollapsed(isCollapsed: Boolean): Concrete {
|
||||||
return copy(isCollapsed = isCollapsed)
|
return copy(isCollapsed = isCollapsed)
|
||||||
|
19
app/src/main/res/layout/notifications_filter.xml
Normal file
19
app/src/main/res/layout/notifications_filter.xml
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:orientation="vertical" android:layout_width="200dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:background="?android:attr/windowBackground">
|
||||||
|
<ListView
|
||||||
|
android:id="@+id/listView"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="0dp"
|
||||||
|
android:layout_weight="1" />
|
||||||
|
<Button
|
||||||
|
android:id="@+id/buttonApply"
|
||||||
|
style="@style/TuskyButton.TextButton"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_weight="0"
|
||||||
|
android:text="@string/filter_apply"
|
||||||
|
android:textSize="?attr/status_text_medium" />
|
||||||
|
</LinearLayout>
|
Loading…
x
Reference in New Issue
Block a user