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
21 changed files with 2279 additions and 1565 deletions
|
@ -20,10 +20,10 @@ import androidx.annotation.DrawableRes
|
|||
import androidx.annotation.StringRes
|
||||
import androidx.fragment.app.Fragment
|
||||
import com.keylesspalace.tusky.components.conversation.ConversationsFragment
|
||||
import com.keylesspalace.tusky.components.notifications.NotificationsFragment
|
||||
import com.keylesspalace.tusky.components.timeline.TimelineFragment
|
||||
import com.keylesspalace.tusky.components.timeline.viewmodel.TimelineViewModel
|
||||
import com.keylesspalace.tusky.components.trending.TrendingTagsFragment
|
||||
import com.keylesspalace.tusky.fragment.NotificationsFragment
|
||||
import java.util.Objects
|
||||
|
||||
/** this would be a good case for a sealed class, but that does not work nice with Room */
|
||||
|
|
|
@ -21,12 +21,10 @@ import android.text.Spanned
|
|||
import android.text.style.StyleSpan
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.keylesspalace.tusky.R
|
||||
import com.keylesspalace.tusky.components.notifications.NotificationsPagingAdapter
|
||||
import com.keylesspalace.tusky.databinding.ItemFollowRequestBinding
|
||||
import com.keylesspalace.tusky.entity.TimelineAccount
|
||||
import com.keylesspalace.tusky.interfaces.AccountActionListener
|
||||
import com.keylesspalace.tusky.interfaces.LinkListener
|
||||
import com.keylesspalace.tusky.util.StatusDisplayOptions
|
||||
import com.keylesspalace.tusky.util.emojify
|
||||
import com.keylesspalace.tusky.util.hide
|
||||
import com.keylesspalace.tusky.util.loadAvatar
|
||||
|
@ -35,33 +33,12 @@ import com.keylesspalace.tusky.util.setClickableText
|
|||
import com.keylesspalace.tusky.util.show
|
||||
import com.keylesspalace.tusky.util.unicodeWrap
|
||||
import com.keylesspalace.tusky.util.visible
|
||||
import com.keylesspalace.tusky.viewdata.NotificationViewData
|
||||
|
||||
class FollowRequestViewHolder(
|
||||
private val binding: ItemFollowRequestBinding,
|
||||
private val accountActionListener: AccountActionListener,
|
||||
private val linkListener: LinkListener,
|
||||
private val showHeader: Boolean
|
||||
) : NotificationsPagingAdapter.ViewHolder, RecyclerView.ViewHolder(binding.root) {
|
||||
|
||||
override fun bind(
|
||||
viewData: NotificationViewData,
|
||||
payloads: List<*>?,
|
||||
statusDisplayOptions: StatusDisplayOptions
|
||||
) {
|
||||
// Skip updates with payloads. That indicates a timestamp update, and
|
||||
// this view does not have timestamps.
|
||||
if (!payloads.isNullOrEmpty()) return
|
||||
|
||||
setupWithAccount(
|
||||
viewData.account,
|
||||
statusDisplayOptions.animateAvatars,
|
||||
statusDisplayOptions.animateEmojis,
|
||||
statusDisplayOptions.showBotOverlay
|
||||
)
|
||||
|
||||
setupActionListener(accountActionListener, viewData.account.id)
|
||||
}
|
||||
) : RecyclerView.ViewHolder(binding.root) {
|
||||
|
||||
fun setupWithAccount(
|
||||
account: TimelineAccount,
|
||||
|
@ -70,24 +47,12 @@ class FollowRequestViewHolder(
|
|||
showBotOverlay: Boolean
|
||||
) {
|
||||
val wrappedName = account.name.unicodeWrap()
|
||||
val emojifiedName: CharSequence = wrappedName.emojify(
|
||||
account.emojis,
|
||||
itemView,
|
||||
animateEmojis
|
||||
)
|
||||
val emojifiedName: CharSequence = wrappedName.emojify(account.emojis, itemView, animateEmojis)
|
||||
binding.displayNameTextView.text = emojifiedName
|
||||
if (showHeader) {
|
||||
val wholeMessage: String = itemView.context.getString(
|
||||
R.string.notification_follow_request_format,
|
||||
wrappedName
|
||||
)
|
||||
val wholeMessage: String = itemView.context.getString(R.string.notification_follow_request_format, wrappedName)
|
||||
binding.notificationTextView.text = SpannableStringBuilder(wholeMessage).apply {
|
||||
setSpan(
|
||||
StyleSpan(Typeface.BOLD),
|
||||
0,
|
||||
wrappedName.length,
|
||||
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
|
||||
)
|
||||
setSpan(StyleSpan(Typeface.BOLD), 0, wrappedName.length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
|
||||
}.emojify(account.emojis, itemView, animateEmojis)
|
||||
}
|
||||
binding.notificationTextView.visible(showHeader)
|
||||
|
|
|
@ -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 at.connyduck.sparkbutton.helpers.Utils
|
||||
import com.keylesspalace.tusky.R
|
||||
import com.keylesspalace.tusky.components.notifications.NotificationActionListener
|
||||
import com.keylesspalace.tusky.components.notifications.NotificationsPagingAdapter
|
||||
import com.keylesspalace.tusky.adapter.NotificationsAdapter.NotificationActionListener
|
||||
import com.keylesspalace.tusky.databinding.ItemReportNotificationBinding
|
||||
import com.keylesspalace.tusky.entity.Report
|
||||
import com.keylesspalace.tusky.entity.TimelineAccount
|
||||
import com.keylesspalace.tusky.util.StatusDisplayOptions
|
||||
import com.keylesspalace.tusky.util.emojify
|
||||
import com.keylesspalace.tusky.util.getRelativeTimeSpanString
|
||||
import com.keylesspalace.tusky.util.loadAvatar
|
||||
import com.keylesspalace.tusky.util.unicodeWrap
|
||||
import com.keylesspalace.tusky.viewdata.NotificationViewData
|
||||
import java.util.Date
|
||||
|
||||
class ReportNotificationViewHolder(
|
||||
private val binding: ItemReportNotificationBinding,
|
||||
private val notificationActionListener: NotificationActionListener
|
||||
) : NotificationsPagingAdapter.ViewHolder, RecyclerView.ViewHolder(binding.root) {
|
||||
) : RecyclerView.ViewHolder(binding.root) {
|
||||
|
||||
override fun bind(
|
||||
viewData: NotificationViewData,
|
||||
payloads: List<*>?,
|
||||
statusDisplayOptions: StatusDisplayOptions
|
||||
) {
|
||||
// Skip updates with payloads. That indicates a timestamp update, and
|
||||
// this view does not have timestamps.
|
||||
if (!payloads.isNullOrEmpty()) return
|
||||
|
||||
setupWithReport(
|
||||
viewData.account,
|
||||
viewData.report!!,
|
||||
statusDisplayOptions.animateAvatars,
|
||||
statusDisplayOptions.animateEmojis
|
||||
)
|
||||
setupActionListener(
|
||||
notificationActionListener,
|
||||
viewData.report.targetAccount.id,
|
||||
viewData.account.id,
|
||||
viewData.report.id
|
||||
)
|
||||
}
|
||||
|
||||
private fun setupWithReport(
|
||||
reporter: TimelineAccount,
|
||||
report: Report,
|
||||
animateAvatar: Boolean,
|
||||
animateEmojis: Boolean
|
||||
) {
|
||||
val reporterName = reporter.name.unicodeWrap().emojify(
|
||||
reporter.emojis,
|
||||
binding.root,
|
||||
animateEmojis
|
||||
)
|
||||
val reporteeName = report.targetAccount.name.unicodeWrap().emojify(
|
||||
report.targetAccount.emojis,
|
||||
itemView,
|
||||
animateEmojis
|
||||
)
|
||||
val icon = ContextCompat.getDrawable(binding.root.context, R.drawable.ic_flag_24dp)
|
||||
fun setupWithReport(reporter: TimelineAccount, report: Report, animateAvatar: Boolean, animateEmojis: Boolean) {
|
||||
val reporterName = reporter.name.unicodeWrap().emojify(reporter.emojis, itemView, animateEmojis)
|
||||
val reporteeName = report.targetAccount.name.unicodeWrap().emojify(report.targetAccount.emojis, itemView, animateEmojis)
|
||||
val icon = ContextCompat.getDrawable(itemView.context, R.drawable.ic_flag_24dp)
|
||||
|
||||
binding.notificationTopText.setCompoundDrawablesWithIntrinsicBounds(icon, null, null, null)
|
||||
binding.notificationTopText.text = itemView.context.getString(
|
||||
R.string.notification_header_report_format,
|
||||
reporterName,
|
||||
reporteeName
|
||||
)
|
||||
binding.notificationSummary.text = itemView.context.getString(
|
||||
R.string.notification_summary_report_format,
|
||||
getRelativeTimeSpanString(itemView.context, report.createdAt.time, Date().time),
|
||||
report.status_ids?.size ?: 0
|
||||
)
|
||||
binding.notificationTopText.text = itemView.context.getString(R.string.notification_header_report_format, reporterName, reporteeName)
|
||||
binding.notificationSummary.text = itemView.context.getString(R.string.notification_summary_report_format, getRelativeTimeSpanString(itemView.context, report.createdAt.time, Date().time), report.status_ids?.size ?: 0)
|
||||
binding.notificationCategory.text = getTranslatedCategory(itemView.context, report.category)
|
||||
|
||||
// Fancy avatar inset
|
||||
|
@ -100,22 +52,17 @@ class ReportNotificationViewHolder(
|
|||
report.targetAccount.avatar,
|
||||
binding.notificationReporteeAvatar,
|
||||
itemView.context.resources.getDimensionPixelSize(R.dimen.avatar_radius_36dp),
|
||||
animateAvatar
|
||||
animateAvatar,
|
||||
)
|
||||
loadAvatar(
|
||||
reporter.avatar,
|
||||
binding.notificationReporterAvatar,
|
||||
itemView.context.resources.getDimensionPixelSize(R.dimen.avatar_radius_24dp),
|
||||
animateAvatar
|
||||
animateAvatar,
|
||||
)
|
||||
}
|
||||
|
||||
private fun setupActionListener(
|
||||
listener: NotificationActionListener,
|
||||
reporteeId: String,
|
||||
reporterId: String,
|
||||
reportId: String
|
||||
) {
|
||||
fun setupActionListener(listener: NotificationActionListener, reporteeId: String, reporterId: String, reportId: String) {
|
||||
binding.notificationReporteeAvatar.setOnClickListener {
|
||||
val position = bindingAdapterPosition
|
||||
if (position != RecyclerView.NO_POSITION) {
|
||||
|
|
|
@ -1,5 +1,7 @@
|
|||
package com.keylesspalace.tusky.appstore
|
||||
|
||||
import io.reactivex.rxjava3.core.Observable
|
||||
import io.reactivex.rxjava3.subjects.PublishSubject
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import javax.inject.Inject
|
||||
|
@ -13,7 +15,12 @@ class EventHub @Inject constructor() {
|
|||
private val sharedEventFlow: MutableSharedFlow<Event> = MutableSharedFlow()
|
||||
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) {
|
||||
sharedEventFlow.emit(event)
|
||||
eventsSubject.onNext(event)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -44,7 +44,6 @@ class FollowRequestsAdapter(
|
|||
)
|
||||
return FollowRequestViewHolder(
|
||||
binding,
|
||||
accountActionListener,
|
||||
linkListener,
|
||||
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.conversation.ConversationsFragment
|
||||
import com.keylesspalace.tusky.components.domainblocks.DomainBlocksFragment
|
||||
import com.keylesspalace.tusky.components.notifications.NotificationsFragment
|
||||
import com.keylesspalace.tusky.components.preference.AccountPreferencesFragment
|
||||
import com.keylesspalace.tusky.components.preference.NotificationPreferencesFragment
|
||||
import com.keylesspalace.tusky.components.preference.PreferencesFragment
|
||||
|
@ -35,6 +34,7 @@ import com.keylesspalace.tusky.components.timeline.TimelineFragment
|
|||
import com.keylesspalace.tusky.components.trending.TrendingTagsFragment
|
||||
import com.keylesspalace.tusky.components.viewthread.ViewThreadFragment
|
||||
import com.keylesspalace.tusky.components.viewthread.edits.ViewEditsFragment
|
||||
import com.keylesspalace.tusky.fragment.NotificationsFragment
|
||||
import com.keylesspalace.tusky.fragment.ViewVideoFragment
|
||||
import dagger.Module
|
||||
import dagger.android.ContributesAndroidInjector
|
||||
|
|
|
@ -110,6 +110,9 @@ data class Notification(
|
|||
}
|
||||
}
|
||||
|
||||
/** Helper for Java */
|
||||
fun copyWithStatus(status: Status?): Notification = copy(status = status)
|
||||
|
||||
// for Pleroma compatibility that uses Mention type
|
||||
fun rewriteToStatusTypeIfNeeded(accountId: String): Notification {
|
||||
if (type == Type.MENTION && status != null) {
|
||||
|
|
File diff suppressed because it is too large
Load diff
|
@ -144,6 +144,14 @@ interface MastodonApi {
|
|||
@Query("exclude_types[]") excludes: Set<Notification.Type>? = null
|
||||
): 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 */
|
||||
@GET("api/v1/notifications/{id}")
|
||||
suspend fun notification(
|
||||
|
@ -177,6 +185,9 @@ interface MastodonApi {
|
|||
@POST("api/v1/notifications/clear")
|
||||
suspend fun clearNotifications(): Response<ResponseBody>
|
||||
|
||||
@POST("api/v1/notifications/clear")
|
||||
fun clearNotificationsOld(): Single<ResponseBody>
|
||||
|
||||
@FormUrlEncoded
|
||||
@PUT("api/v1/media/{mediaId}")
|
||||
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(
|
||||
isShowingContent: Boolean,
|
||||
isExpanded: Boolean,
|
||||
isCollapsed: Boolean
|
||||
): NotificationViewData {
|
||||
return NotificationViewData(
|
||||
): NotificationViewData.Concrete {
|
||||
return NotificationViewData.Concrete(
|
||||
this.type,
|
||||
this.id,
|
||||
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.TimelineAccount
|
||||
|
||||
data class NotificationViewData(
|
||||
data class NotificationViewDataX(
|
||||
val type: Notification.Type,
|
||||
val id: String,
|
||||
val account: TimelineAccount,
|
||||
|
|
|
@ -92,6 +92,21 @@ sealed class StatusViewData {
|
|||
this.isCollapsible = shouldTrimStatus(this.content)
|
||||
}
|
||||
|
||||
/** Helper for Java */
|
||||
fun copyWithStatus(status: Status): Concrete {
|
||||
return copy(status = status)
|
||||
}
|
||||
|
||||
/** Helper for Java */
|
||||
fun copyWithExpanded(isExpanded: Boolean): Concrete {
|
||||
return copy(isExpanded = isExpanded)
|
||||
}
|
||||
|
||||
/** Helper for Java */
|
||||
fun copyWithShowingContent(isShowingContent: Boolean): Concrete {
|
||||
return copy(isShowingContent = isShowingContent)
|
||||
}
|
||||
|
||||
/** Helper for Java */
|
||||
fun copyWithCollapsed(isCollapsed: Boolean): Concrete {
|
||||
return copy(isCollapsed = isCollapsed)
|
||||
|
|
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…
Reference in a new issue