Add support for moderation report notifications (#2887)

* Add support for moderation report notifications

* Translate report categories

* Apply tint inside flag drawable

* Remove unused imports

Co-authored-by: Konrad Pozniak <connyduck@users.noreply.github.com>
This commit is contained in:
Levi Bard 2022-12-01 20:11:55 +01:00 committed by GitHub
commit 6b95790457
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
18 changed files with 1257 additions and 11 deletions

View file

@ -42,6 +42,7 @@ 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;
@ -80,7 +81,8 @@ public class NotificationsAdapter extends RecyclerView.Adapter {
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_UNKNOWN = 5;
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];
@ -137,6 +139,10 @@ public class NotificationsAdapter extends RecyclerView.Adapter {
.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());
@ -252,6 +258,13 @@ public class NotificationsAdapter extends RecyclerView.Adapter {
}
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:
}
}
@ -304,6 +317,9 @@ public class NotificationsAdapter extends RecyclerView.Adapter {
case FOLLOW_REQUEST: {
return VIEW_TYPE_FOLLOW_REQUEST;
}
case REPORT: {
return VIEW_TYPE_REPORT;
}
default: {
return VIEW_TYPE_UNKNOWN;
}
@ -322,6 +338,8 @@ public class NotificationsAdapter extends RecyclerView.Adapter {
void onViewStatusForNotificationId(String notificationId);
void onViewReport(String reportId);
void onExpandedChange(boolean expanded, int position);
/**

View file

@ -0,0 +1,90 @@
/* 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 androidx.core.content.ContextCompat
import androidx.recyclerview.widget.RecyclerView
import at.connyduck.sparkbutton.helpers.Utils
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.adapter.NotificationsAdapter.NotificationActionListener
import com.keylesspalace.tusky.databinding.ItemReportNotificationBinding
import com.keylesspalace.tusky.entity.Report
import com.keylesspalace.tusky.entity.TimelineAccount
import com.keylesspalace.tusky.util.TimestampUtils
import com.keylesspalace.tusky.util.emojify
import com.keylesspalace.tusky.util.loadAvatar
import com.keylesspalace.tusky.util.unicodeWrap
import java.util.Date
class ReportNotificationViewHolder(
private val binding: ItemReportNotificationBinding,
) : RecyclerView.ViewHolder(binding.root) {
fun setupWithReport(reporter: TimelineAccount, report: Report, animateAvatar: Boolean, animateEmojis: Boolean) {
val reporterName = reporter.name.unicodeWrap().emojify(reporter.emojis, itemView, animateEmojis)
val reporteeName = report.targetAccount.name.unicodeWrap().emojify(report.targetAccount.emojis, itemView, animateEmojis)
val icon = ContextCompat.getDrawable(itemView.context, R.drawable.ic_flag_24dp)
binding.notificationTopText.setCompoundDrawablesWithIntrinsicBounds(icon, null, null, null)
binding.notificationTopText.text = itemView.context.getString(R.string.notification_header_report_format, reporterName, reporteeName)
binding.notificationSummary.text = itemView.context.getString(R.string.notification_summary_report_format, TimestampUtils.getRelativeTimeSpanString(itemView.context, report.createdAt.time, Date().time), report.status_ids?.size ?: 0)
binding.notificationCategory.text = getTranslatedCategory(itemView.context, report.category)
// Fancy avatar inset
val padding = Utils.dpToPx(binding.notificationReporteeAvatar.context, 12)
binding.notificationReporteeAvatar.setPaddingRelative(0, 0, padding, padding)
loadAvatar(
report.targetAccount.avatar,
binding.notificationReporteeAvatar,
itemView.context.resources.getDimensionPixelSize(R.dimen.avatar_radius_36dp),
animateAvatar,
)
loadAvatar(
reporter.avatar,
binding.notificationReporterAvatar,
itemView.context.resources.getDimensionPixelSize(R.dimen.avatar_radius_24dp),
animateAvatar,
)
}
fun setupActionListener(listener: NotificationActionListener, reporteeId: String, reporterId: String, reportId: String) {
binding.notificationReporteeAvatar.setOnClickListener {
val position = bindingAdapterPosition
if (position != RecyclerView.NO_POSITION) {
listener.onViewAccount(reporteeId)
}
}
binding.notificationReporterAvatar.setOnClickListener {
val position = bindingAdapterPosition
if (position != RecyclerView.NO_POSITION) {
listener.onViewAccount(reporterId)
}
}
itemView.setOnClickListener { listener.onViewReport(reportId) }
}
private fun getTranslatedCategory(context: Context, rawCategory: String): String {
return when (rawCategory) {
"violation" -> context.getString(R.string.report_category_violation)
"spam" -> context.getString(R.string.report_category_spam)
"other" -> context.getString(R.string.report_category_other)
else -> rawCategory
}
}
}

View file

@ -120,6 +120,7 @@ public class NotificationHelper {
public static final String CHANNEL_SUBSCRIPTIONS = "CHANNEL_SUBSCRIPTIONS";
public static final String CHANNEL_SIGN_UP = "CHANNEL_SIGN_UP";
public static final String CHANNEL_UPDATES = "CHANNEL_UPDATES";
public static final String CHANNEL_REPORT = "CHANNEL_REPORT";
/**
* WorkManager Tag
@ -401,6 +402,7 @@ public class NotificationHelper {
CHANNEL_SUBSCRIPTIONS + account.getIdentifier(),
CHANNEL_SIGN_UP + account.getIdentifier(),
CHANNEL_UPDATES + account.getIdentifier(),
CHANNEL_REPORT + account.getIdentifier(),
};
int[] channelNames = {
R.string.notification_mention_name,
@ -412,6 +414,7 @@ public class NotificationHelper {
R.string.notification_subscription_name,
R.string.notification_sign_up_name,
R.string.notification_update_name,
R.string.notification_report_name,
};
int[] channelDescriptions = {
R.string.notification_mention_descriptions,
@ -423,6 +426,7 @@ public class NotificationHelper {
R.string.notification_subscription_description,
R.string.notification_sign_up_description,
R.string.notification_update_description,
R.string.notification_report_description,
};
List<NotificationChannel> channels = new ArrayList<>(6);
@ -564,6 +568,8 @@ public class NotificationHelper {
return account.getNotificationsSignUps();
case UPDATE:
return account.getNotificationsUpdates();
case REPORT:
return account.getNotificationsReports();
default:
return false;
}
@ -593,6 +599,10 @@ public class NotificationHelper {
return CHANNEL_POLL + account.getIdentifier();
case SIGN_UP:
return CHANNEL_SIGN_UP + account.getIdentifier();
case UPDATE:
return CHANNEL_UPDATES + account.getIdentifier();
case REPORT:
return CHANNEL_REPORT + account.getIdentifier();
default:
return null;
}
@ -678,6 +688,8 @@ public class NotificationHelper {
return String.format(context.getString(R.string.notification_sign_up_format), accountName);
case UPDATE:
return String.format(context.getString(R.string.notification_update_format), accountName);
case REPORT:
return context.getString(R.string.notification_report_format, account.getDomain());
}
return null;
}
@ -715,6 +727,12 @@ public class NotificationHelper {
}
return builder.toString();
}
case REPORT:
return context.getString(
R.string.notification_header_report_format,
StringUtils.unicodeWrap(notification.getAccount().getName()),
StringUtils.unicodeWrap(notification.getReport().getTargetAccount().getName())
);
}
return null;
}

View file

@ -144,6 +144,17 @@ class NotificationPreferencesFragment : PreferenceFragmentCompat(), Injectable {
true
}
}
switchPreference {
setTitle(R.string.pref_title_notification_filter_reports)
key = PrefKeys.NOTIFICATION_FILTER_REPORTS
isIconSpaceReserved = false
isChecked = activeAccount.notificationsReports
setOnPreferenceChangeListener { _, newValue ->
updateAccount { it.notificationsReports = newValue as Boolean }
true
}
}
}
preferenceCategory(R.string.pref_title_notification_alerts) { category ->

View file

@ -54,6 +54,7 @@ data class AccountEntity(
var notificationsSubscriptions: Boolean = true,
var notificationsSignUps: Boolean = true,
var notificationsUpdates: Boolean = true,
var notificationsReports: Boolean = true,
var notificationSound: Boolean = true,
var notificationVibration: Boolean = true,
var notificationLight: Boolean = true,

View file

@ -31,7 +31,7 @@ import java.io.File;
*/
@Database(entities = { DraftEntity.class, AccountEntity.class, InstanceEntity.class, TimelineStatusEntity.class,
TimelineAccountEntity.class, ConversationEntity.class
}, version = 43)
}, version = 44)
public abstract class AppDatabase extends RoomDatabase {
public abstract AccountDao accountDao();
@ -617,4 +617,11 @@ public abstract class AppDatabase extends RoomDatabase {
database.execSQL("ALTER TABLE `AccountEntity` ADD COLUMN `defaultPostLanguage` TEXT NOT NULL DEFAULT ''");
}
};
public static final Migration MIGRATION_43_44 = new Migration(43, 44) {
@Override
public void migrate(@NonNull SupportSQLiteDatabase database) {
database.execSQL("ALTER TABLE `AccountEntity` ADD COLUMN `notificationsReports` INTEGER NOT NULL DEFAULT 1");
}
};
}

View file

@ -66,7 +66,7 @@ class AppModule {
AppDatabase.MIGRATION_32_33, AppDatabase.MIGRATION_33_34, AppDatabase.MIGRATION_34_35,
AppDatabase.MIGRATION_35_36, AppDatabase.MIGRATION_36_37, AppDatabase.MIGRATION_37_38,
AppDatabase.MIGRATION_38_39, AppDatabase.MIGRATION_39_40, AppDatabase.MIGRATION_40_41,
AppDatabase.MIGRATION_41_42, AppDatabase.MIGRATION_42_43,
AppDatabase.MIGRATION_41_42, AppDatabase.MIGRATION_42_43, AppDatabase.MIGRATION_43_44,
)
.build()
}

View file

@ -25,7 +25,8 @@ data class Notification(
val type: Type,
val id: String,
val account: TimelineAccount,
val status: Status?
val status: Status?,
val report: Report?,
) {
@JsonAdapter(NotificationTypeAdapter::class)
@ -40,6 +41,7 @@ data class Notification(
STATUS("status"),
SIGN_UP("admin.sign_up"),
UPDATE("update"),
REPORT("admin.report"),
;
companion object {
@ -52,7 +54,7 @@ data class Notification(
}
return UNKNOWN
}
val asList = listOf(MENTION, REBLOG, FAVOURITE, FOLLOW, FOLLOW_REQUEST, POLL, STATUS, SIGN_UP, UPDATE)
val asList = listOf(MENTION, REBLOG, FAVOURITE, FOLLOW, FOLLOW_REQUEST, POLL, STATUS, SIGN_UP, UPDATE, REPORT)
}
override fun toString(): String {

View file

@ -0,0 +1,12 @@
package com.keylesspalace.tusky.entity
import com.google.gson.annotations.SerializedName
import java.util.Date
data class Report(
val id: String,
val category: String,
val status_ids: List<String>?,
@SerializedName("created_at") val createdAt: Date,
@SerializedName("target_account") val targetAccount: TimelineAccount,
)

View file

@ -80,6 +80,7 @@ import com.keylesspalace.tusky.settings.PrefKeys;
import com.keylesspalace.tusky.util.CardViewMode;
import com.keylesspalace.tusky.util.Either;
import com.keylesspalace.tusky.util.HttpHeaderLink;
import com.keylesspalace.tusky.util.LinkHelper;
import com.keylesspalace.tusky.util.ListStatusAccessibilityDelegate;
import com.keylesspalace.tusky.util.ListUtils;
import com.keylesspalace.tusky.util.NotificationTypeConverterKt;
@ -711,6 +712,8 @@ public class NotificationsFragment extends SFragment implements
return getString(R.string.notification_sign_up_name);
case UPDATE:
return getString(R.string.notification_update_name);
case REPORT:
return getString(R.string.notification_report_name);
default:
return "Unknown";
}
@ -800,6 +803,11 @@ public class NotificationsFragment extends SFragment implements
Log.w(TAG, "Didn't find a notification for ID: " + notificationId);
}
@Override
public void onViewReport(String reportId) {
LinkHelper.openLink(getContext(), String.format("https://%s/admin/reports/%s", accountManager.getActiveAccount().getDomain(), reportId));
}
private void onPreferenceChanged(String key) {
switch (key) {
case "fabHide": {

View file

@ -62,6 +62,7 @@ object PrefKeys {
const val NOTIFICATION_FILTER_SUBSCRIPTIONS = "notificationFilterSubscriptions"
const val NOTIFICATION_FILTER_SIGN_UPS = "notificationFilterSignUps"
const val NOTIFICATION_FILTER_UPDATES = "notificationFilterUpdates"
const val NOTIFICATION_FILTER_REPORTS = "notificationFilterReports"
const val TAB_FILTER_HOME_REPLIES = "tabFilterHomeReplies_v2" // This was changed once to reset an unintentionally set default.
const val TAB_FILTER_HOME_BOOSTS = "tabFilterHomeBoosts"

View file

@ -47,6 +47,7 @@ fun Notification.toViewData(
this.type,
this.id,
this.account,
this.status?.toViewData(isShowingContent, isExpanded, isCollapsed)
this.status?.toViewData(isShowingContent, isExpanded, isCollapsed),
this.report,
)
}

View file

@ -17,8 +17,8 @@ package com.keylesspalace.tusky.viewdata;
import androidx.annotation.Nullable;
import com.keylesspalace.tusky.entity.Account;
import com.keylesspalace.tusky.entity.Notification;
import com.keylesspalace.tusky.entity.Report;
import com.keylesspalace.tusky.entity.TimelineAccount;
import java.util.Objects;
@ -48,13 +48,16 @@ public abstract class NotificationViewData {
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 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() {
@ -74,6 +77,11 @@ public abstract class NotificationViewData {
return statusViewData;
}
@Nullable
public Report getReport() {
return report;
}
@Override
public long getViewDataId() {
return id.hashCode();
@ -87,7 +95,8 @@ public abstract class NotificationViewData {
return type == concrete.type &&
Objects.equals(id, concrete.id) &&
account.getId().equals(concrete.account.getId()) &&
(Objects.equals(statusViewData, concrete.statusViewData));
(Objects.equals(statusViewData, concrete.statusViewData)) &&
(Objects.equals(report, concrete.report));
}
@Override
@ -97,7 +106,7 @@ public abstract class NotificationViewData {
}
public Concrete copyWithStatus(@Nullable StatusViewData.Concrete statusViewData) {
return new Concrete(type, id, account, statusViewData);
return new Concrete(type, id, account, statusViewData, report);
}
}