/* Copyright 2017 Andrew Dawson * * This file is a part of Tusky. * * This program is free software; you can redistribute it and/or modify it under the terms of the * GNU General Public License as published by the Free Software Foundation; either version 3 of the * License, or (at your option) any later version. * * Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General * Public License for more details. * * You should have received a copy of the GNU General Public License along with Tusky; if not, * see . */ package com.keylesspalace.tusky.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.NonNull; import androidx.annotation.Nullable; import androidx.core.content.ContextCompat; import androidx.core.text.BidiFormatter; import androidx.recyclerview.widget.RecyclerView; import com.keylesspalace.tusky.R; import com.keylesspalace.tusky.entity.Account; 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.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.TimestampUtils; import com.keylesspalace.tusky.viewdata.NotificationViewData; import com.keylesspalace.tusky.viewdata.StatusViewData; import com.mikepenz.iconics.utils.Utils; import java.text.SimpleDateFormat; import java.util.Date; import java.util.List; import java.util.Locale; public class NotificationsAdapter extends RecyclerView.Adapter { public interface AdapterDataSource { int getItemCount(); T getItemAt(int pos); } private static final int VIEW_TYPE_STATUS = 0; private static final int VIEW_TYPE_STATUS_NOTIFICATION = 1; private static final int VIEW_TYPE_FOLLOW = 2; private static final int VIEW_TYPE_PLACEHOLDER = 3; private static final int VIEW_TYPE_MUTED_STATUS = 4; 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 String accountId; private StatusDisplayOptions statusDisplayOptions; private StatusActionListener statusListener; private NotificationActionListener notificationActionListener; private BidiFormatter bidiFormatter; private AdapterDataSource dataSource; public NotificationsAdapter(String accountId, AdapterDataSource dataSource, StatusDisplayOptions statusDisplayOptions, StatusActionListener statusListener, NotificationActionListener notificationActionListener) { this.accountId = accountId; this.dataSource = dataSource; this.statusDisplayOptions = statusDisplayOptions; this.statusListener = statusListener; this.notificationActionListener = notificationActionListener; bidiFormatter = BidiFormatter.getInstance(); } @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_MUTED_STATUS: { View view = inflater .inflate(R.layout.item_status_muted, parent, false); return new MutedStatusViewHolder(view); } case VIEW_TYPE_STATUS_NOTIFICATION: { View view = inflater .inflate(R.layout.item_status_notification, parent, false); return new StatusNotificationViewHolder(view, statusDisplayOptions); } case VIEW_TYPE_FOLLOW: { View view = inflater .inflate(R.layout.item_follow, parent, false); return new FollowViewHolder(view, statusDisplayOptions); } case VIEW_TYPE_PLACEHOLDER: { View view = inflater .inflate(R.layout.item_status_placeholder, parent, false); return new PlaceholderViewHolder(view); } default: case VIEW_TYPE_UNKNOWN: { View view = new View(parent.getContext()); view.setLayoutParams( new ViewGroup.LayoutParams( ViewGroup.LayoutParams.MATCH_PARENT, Utils.convertDpToPx(parent.getContext(), 24) ) ); return new RecyclerView.ViewHolder(view) { }; } } } @Override public void onBindViewHolder(@NonNull RecyclerView.ViewHolder viewHolder, int position) { bindViewHolder(viewHolder, position, null); } @Override public void onBindViewHolder(@NonNull RecyclerView.ViewHolder viewHolder, int position, @NonNull List payloads) { bindViewHolder(viewHolder, position, payloads); } private void bindViewHolder(@NonNull RecyclerView.ViewHolder viewHolder, int position, @Nullable List payloads) { Object payloadForHolder = payloads != null && !payloads.isEmpty() ? payloads.get(0) : null; if (position < this.dataSource.getItemCount()) { NotificationViewData notification = dataSource.getItemAt(position); if (notification instanceof NotificationViewData.Placeholder) { if (payloadForHolder == null) { NotificationViewData.Placeholder placeholder = ((NotificationViewData.Placeholder) notification); PlaceholderViewHolder holder = (PlaceholderViewHolder) viewHolder; holder.setup(statusListener, placeholder.isLoading()); } return; } NotificationViewData.Concrete concreteNotificaton = (NotificationViewData.Concrete) notification; switch (viewHolder.getItemViewType()) { case VIEW_TYPE_STATUS: { StatusViewHolder holder = (StatusViewHolder) viewHolder; StatusViewData.Concrete status = concreteNotificaton.getStatusViewData(); holder.setupWithStatus(status, statusListener, statusDisplayOptions, payloadForHolder); if (concreteNotificaton.getType() == Notification.Type.POLL) { holder.setPollInfo(accountId.equals(concreteNotificaton.getAccount().getId())); } else { holder.hideStatusInfo(); } break; } case VIEW_TYPE_MUTED_STATUS: { MutedStatusViewHolder holder = (MutedStatusViewHolder) viewHolder; StatusViewData.Concrete status = concreteNotificaton.getStatusViewData(); holder.setupWithStatus(status, statusListener, statusDisplayOptions, payloadForHolder); break; } case VIEW_TYPE_STATUS_NOTIFICATION: { StatusNotificationViewHolder holder = (StatusNotificationViewHolder) viewHolder; StatusViewData.Concrete statusViewData = concreteNotificaton.getStatusViewData(); if (payloadForHolder == null) { if (statusViewData == null) { holder.showNotificationContent(false); } else { holder.showNotificationContent(true); holder.setDisplayName(statusViewData.getUserFullName(), statusViewData.getAccountEmojis()); holder.setUsername(statusViewData.getNickname()); holder.setCreatedAt(statusViewData.getCreatedAt()); holder.setAvatars(concreteNotificaton.getStatusViewData().getAvatar(), concreteNotificaton.getAccount().getAvatar()); } holder.setMessage(concreteNotificaton, statusListener, bidiFormatter); holder.setupButtons(notificationActionListener, concreteNotificaton.getAccount().getId(), concreteNotificaton.getId()); } else { if (payloadForHolder instanceof List) for (Object item : (List) payloadForHolder) { if (StatusBaseViewHolder.Key.KEY_CREATED.equals(item) && statusViewData != null) { holder.setCreatedAt(statusViewData.getCreatedAt()); } } } break; } case VIEW_TYPE_FOLLOW: { if (payloadForHolder == null) { FollowViewHolder holder = (FollowViewHolder) viewHolder; holder.setMessage(concreteNotificaton.getAccount(), bidiFormatter); holder.setupButtons(notificationActionListener, concreteNotificaton.getAccount().getId()); } break; } 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() ); } 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: { if(concrete.getStatusViewData() != null && concrete.getStatusViewData().isThreadMuted()) return VIEW_TYPE_MUTED_STATUS; return VIEW_TYPE_STATUS; } case FAVOURITE: case REBLOG: case EMOJI_REACTION: { return VIEW_TYPE_STATUS_NOTIFICATION; } case FOLLOW: { return VIEW_TYPE_FOLLOW; } 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 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 TextView message; private TextView usernameView; private TextView displayNameView; private ImageView avatar; private 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(Account account, BidiFormatter bidiFormatter) { Context context = message.getContext(); String format = context.getString(R.string.notification_follow_format); String wrappedDisplayName = bidiFormatter.unicodeWrap(account.getName()); String wholeMessage = String.format(format, wrappedDisplayName); CharSequence emojifiedMessage = CustomEmojiHelper.emojifyString(wholeMessage, account.getEmojis(), message); message.setText(emojifiedMessage); String username = context.getString(R.string.status_username_format, account.getUsername()); usernameView.setText(username); CharSequence emojifiedDisplayName = CustomEmojiHelper.emojifyString(wrappedDisplayName, account.getEmojis(), usernameView); displayNameView.setText(emojifiedDisplayName); int avatarRadius = avatar.getContext().getResources() .getDimensionPixelSize(R.dimen.avatar_radius_42dp); ImageLoadingHelper.loadAvatar(account.getAvatar(), avatar, avatarRadius, statusDisplayOptions.animateAvatars()); } void setupButtons(final NotificationActionListener listener, final String accountId) { avatar.setOnClickListener(v -> listener.onViewAccount(accountId)); } } private static class StatusNotificationViewHolder extends RecyclerView.ViewHolder implements View.OnClickListener { private final TextView message; private final View statusNameBar; private final TextView displayName; private final TextView username; private final TextView timestampInfo; private final TextView statusContent; private final ImageView statusAvatar; private final ImageView notificationAvatar; private final TextView contentWarningDescriptionTextView; private final Button contentWarningButton; private final Button contentCollapseButton; // TODO: This code SHOULD be based on StatusBaseViewHolder private StatusDisplayOptions statusDisplayOptions; private String accountId; private String notificationId; private NotificationActionListener notificationActionListener; private StatusViewData.Concrete statusViewData; private SimpleDateFormat shortSdf; private SimpleDateFormat longSdf; StatusNotificationViewHolder(View itemView, StatusDisplayOptions statusDisplayOptions) { 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_timestamp_info); statusContent = itemView.findViewById(R.id.notification_content); statusAvatar = itemView.findViewById(R.id.notification_status_avatar); notificationAvatar = itemView.findViewById(R.id.notification_notification_avatar); contentWarningDescriptionTextView = itemView.findViewById(R.id.notification_content_warning_description); contentWarningButton = itemView.findViewById(R.id.notification_content_warning_button); contentCollapseButton = itemView.findViewById(R.id.button_toggle_notification_content); this.statusDisplayOptions = statusDisplayOptions; 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); shortSdf = new SimpleDateFormat("HH:mm:ss", Locale.getDefault()); longSdf = new SimpleDateFormat("MM/dd HH:mm:ss", Locale.getDefault()); } private void showNotificationContent(boolean show) { statusNameBar.setVisibility(show ? View.VISIBLE : View.GONE); contentWarningDescriptionTextView.setVisibility(show ? View.VISIBLE : View.GONE); contentWarningButton.setVisibility(show ? View.VISIBLE : View.GONE); statusContent.setVisibility(show ? View.VISIBLE : View.GONE); statusAvatar.setVisibility(show ? View.VISIBLE : View.GONE); notificationAvatar.setVisibility(show ? View.VISIBLE : View.GONE); } private void setDisplayName(String name, List emojis) { CharSequence emojifiedName = CustomEmojiHelper.emojifyString(name, emojis, displayName); displayName.setText(emojifiedName); } private void setUsername(String name) { Context context = username.getContext(); String format = context.getString(R.string.status_username_format); String usernameText = String.format(format, name); username.setText(usernameText); } protected void setCreatedAt(@Nullable Date createdAt) { if (statusDisplayOptions.useAbsoluteTime()) { String time; if (createdAt != null) { if (System.currentTimeMillis() - createdAt.getTime() > 86400000L) { time = longSdf.format(createdAt); } else { time = shortSdf.format(createdAt); } } else { time = "??:??:??"; } timestampInfo.setText(time); } 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); } } void setMessage(NotificationViewData.Concrete notificationViewData, LinkListener listener, BidiFormatter bidiFormatter) { this.statusViewData = notificationViewData.getStatusViewData(); String displayName = bidiFormatter.unicodeWrap(notificationViewData.getAccount().getName()); Notification.Type type = notificationViewData.getType(); Context context = message.getContext(); String wholeMessage; Drawable icon; switch (type) { default: case FAVOURITE: { icon = ContextCompat.getDrawable(context, R.drawable.ic_star_24dp); if (icon != null) { icon.setColorFilter(ContextCompat.getColor(context, R.color.tusky_orange), PorterDuff.Mode.SRC_ATOP); } String format = context.getString(R.string.notification_favourite_format); wholeMessage = String.format(format, displayName); break; } case REBLOG: { icon = ContextCompat.getDrawable(context, R.drawable.ic_repeat_24dp); if (icon != null) { icon.setColorFilter(ContextCompat.getColor(context, R.color.tusky_blue), PorterDuff.Mode.SRC_ATOP); } String format = context.getString(R.string.notification_reblog_format); wholeMessage = String.format(format, displayName); break; } case EMOJI_REACTION: { icon = ContextCompat.getDrawable(context, R.drawable.ic_emoji_24dp); if(icon != null) { icon.setColorFilter(ContextCompat.getColor(context, R.color.tusky_green), PorterDuff.Mode.SRC_ATOP); } String format = context.getString(R.string.notification_emoji_format); String emojiCode = notificationViewData.getEmoji(); wholeMessage = String.format(format, displayName, emojiCode); break; } } message.setCompoundDrawablesWithIntrinsicBounds(icon, null, null, null); final SpannableStringBuilder str = new SpannableStringBuilder(wholeMessage); str.setSpan(new StyleSpan(Typeface.BOLD), 0, displayName.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); CharSequence emojifiedText = CustomEmojiHelper.emojifyText(str, notificationViewData.getAccount().getEmojis(), message); message.setText(emojifiedText); if (statusViewData != null) { boolean hasSpoiler = !TextUtils.isEmpty(statusViewData.getSpoilerText()); contentWarningDescriptionTextView.setVisibility(hasSpoiler ? View.VISIBLE : View.GONE); contentWarningButton.setVisibility(hasSpoiler ? View.VISIBLE : View.GONE); if (statusViewData.isExpanded()) { contentWarningButton.setText(R.string.status_content_warning_show_less); } else { contentWarningButton.setText(R.string.status_content_warning_show_more); } contentWarningButton.setOnClickListener(view -> { if (getAdapterPosition() != RecyclerView.NO_POSITION) { notificationActionListener.onExpandedChange(!statusViewData.isExpanded(), getAdapterPosition()); } statusContent.setVisibility(statusViewData.isExpanded() ? View.GONE : View.VISIBLE); }); setupContentAndSpoiler(notificationViewData, listener); } } void setupButtons(final NotificationActionListener listener, final String accountId, final String notificationId) { this.notificationActionListener = listener; this.accountId = accountId; this.notificationId = notificationId; } void setAvatars(@Nullable String statusAvatarUrl, @Nullable String notificationAvatarUrl) { int statusAvatarRadius = statusAvatar.getContext().getResources() .getDimensionPixelSize(R.dimen.avatar_radius_36dp); ImageLoadingHelper.loadAvatar(statusAvatarUrl, statusAvatar, statusAvatarRadius, statusDisplayOptions.animateAvatars()); int notificationAvatarRadius = statusAvatar.getContext().getResources() .getDimensionPixelSize(R.dimen.avatar_radius_24dp); ImageLoadingHelper.loadAvatar(notificationAvatarUrl, notificationAvatar, notificationAvatarRadius, statusDisplayOptions.animateAvatars()); } @Override public void onClick(View v) { switch (v.getId()) { case R.id.notification_container: case R.id.notification_content: if (notificationActionListener != null) notificationActionListener.onViewStatusForNotificationId(notificationId); break; case R.id.notification_top_text: if (notificationActionListener != null) notificationActionListener.onViewAccount(accountId); break; } } private void setupContentAndSpoiler(NotificationViewData.Concrete notificationViewData, final LinkListener listener) { boolean shouldShowContentIfSpoiler = notificationViewData.isExpanded(); boolean hasSpoiler = !TextUtils.isEmpty(statusViewData.getSpoilerText()); if (!shouldShowContentIfSpoiler && hasSpoiler) { statusContent.setVisibility(View.GONE); } else { statusContent.setVisibility(View.VISIBLE); } Spanned content = statusViewData.getContent(); List emojis = statusViewData.getStatusEmojis(); if (statusViewData.isCollapsible() && (notificationViewData.isExpanded() || !hasSpoiler)) { contentCollapseButton.setOnClickListener(view -> { int position = getAdapterPosition(); if (position != RecyclerView.NO_POSITION && notificationActionListener != null) { notificationActionListener.onNotificationContentCollapsedChange(statusViewData.isCollapsed(), position); } }); contentCollapseButton.setVisibility(View.VISIBLE); if (statusViewData.isCollapsed()) { contentCollapseButton.setText(R.string.status_content_warning_show_more); statusContent.setFilters(COLLAPSE_INPUT_FILTER); } else { contentCollapseButton.setText(R.string.status_content_warning_show_less); statusContent.setFilters(NO_INPUT_FILTER); } } else { contentCollapseButton.setVisibility(View.GONE); statusContent.setFilters(NO_INPUT_FILTER); } Spanned emojifiedText = CustomEmojiHelper.emojifyText(content, emojis, statusContent); LinkHelper.setClickableText(statusContent, emojifiedText, statusViewData.getMentions(), listener); Spanned emojifiedContentWarning = CustomEmojiHelper.emojifyString(statusViewData.getSpoilerText(), statusViewData.getStatusEmojis(), contentWarningDescriptionTextView); contentWarningDescriptionTextView.setText(emojifiedContentWarning); } } }