mirror of https://github.com/tuskyapp/Tusky.git
21 changed files with 2279 additions and 1565 deletions
@ -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) { |
||||
|
||||
} |
||||
} |
||||
@ -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() |
||||
} |
||||
} |
||||
} |
||||
File diff suppressed because it is too large
Load Diff
@ -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 |
||||
} |
||||
@ -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; |
||||
} |
||||
} |
||||
} |
||||
@ -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 new issue