mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-04-22 01:40:07 +01:00
Add remote megaphone.
This commit is contained in:
committed by
Alex Hart
parent
820277800b
commit
bb963f9210
@@ -71,23 +71,23 @@ public class BasicMegaphoneView extends FrameLayout {
|
||||
image.setVisibility(GONE);
|
||||
}
|
||||
|
||||
if (megaphone.getTitle() != 0) {
|
||||
if (megaphone.getTitle().hasText()) {
|
||||
titleText.setVisibility(VISIBLE);
|
||||
titleText.setText(megaphone.getTitle());
|
||||
titleText.setText(megaphone.getTitle().resolve(getContext()));
|
||||
} else {
|
||||
titleText.setVisibility(GONE);
|
||||
}
|
||||
|
||||
if (megaphone.getBody() != 0) {
|
||||
if (megaphone.getBody().hasText()) {
|
||||
bodyText.setVisibility(VISIBLE);
|
||||
bodyText.setText(megaphone.getBody());
|
||||
bodyText.setText(megaphone.getBody().resolve(getContext()));
|
||||
} else {
|
||||
bodyText.setVisibility(GONE);
|
||||
}
|
||||
|
||||
if (megaphone.hasButton()) {
|
||||
actionButton.setVisibility(VISIBLE);
|
||||
actionButton.setText(megaphone.getButtonText());
|
||||
actionButton.setText(megaphone.getButtonText().resolve(getContext()));
|
||||
actionButton.setOnClickListener(v -> {
|
||||
if (megaphone.getButtonClickListener() != null) {
|
||||
megaphone.getButtonClickListener().onEvent(megaphone, megaphoneListener);
|
||||
@@ -109,7 +109,7 @@ public class BasicMegaphoneView extends FrameLayout {
|
||||
}
|
||||
});
|
||||
} else {
|
||||
secondaryButton.setText(megaphone.getSecondaryButtonText());
|
||||
secondaryButton.setText(megaphone.getSecondaryButtonText().resolve(getContext()));
|
||||
secondaryButton.setOnClickListener(v -> {
|
||||
if (megaphone.getSecondaryButtonClickListener() != null) {
|
||||
megaphone.getSecondaryButtonClickListener().onEvent(megaphone, megaphoneListener);
|
||||
|
||||
@@ -20,15 +20,15 @@ public class Megaphone {
|
||||
private final Event event;
|
||||
private final Style style;
|
||||
private final boolean canSnooze;
|
||||
private final int titleRes;
|
||||
private final int bodyRes;
|
||||
private final MegaphoneText titleText;
|
||||
private final MegaphoneText bodyText;
|
||||
private final int imageRes;
|
||||
private final int lottieRes;
|
||||
private final GlideRequest<Drawable> imageRequest;
|
||||
private final int buttonTextRes;
|
||||
private final MegaphoneText buttonText;
|
||||
private final EventListener buttonListener;
|
||||
private final EventListener snoozeListener;
|
||||
private final int secondaryButtonTextRes;
|
||||
private final MegaphoneText secondaryButtonText;
|
||||
private final EventListener secondaryButtonListener;
|
||||
private final EventListener onVisibleListener;
|
||||
|
||||
@@ -36,15 +36,15 @@ public class Megaphone {
|
||||
this.event = builder.event;
|
||||
this.style = builder.style;
|
||||
this.canSnooze = builder.canSnooze;
|
||||
this.titleRes = builder.titleRes;
|
||||
this.bodyRes = builder.bodyRes;
|
||||
this.titleText = builder.titleText;
|
||||
this.bodyText = builder.bodyText;
|
||||
this.imageRes = builder.imageRes;
|
||||
this.lottieRes = builder.lottieRes;
|
||||
this.imageRequest = builder.imageRequest;
|
||||
this.buttonTextRes = builder.buttonTextRes;
|
||||
this.buttonText = builder.buttonText;
|
||||
this.buttonListener = builder.buttonListener;
|
||||
this.snoozeListener = builder.snoozeListener;
|
||||
this.secondaryButtonTextRes = builder.secondaryButtonTextRes;
|
||||
this.secondaryButtonText = builder.secondaryButtonText;
|
||||
this.secondaryButtonListener = builder.secondaryButtonListener;
|
||||
this.onVisibleListener = builder.onVisibleListener;
|
||||
}
|
||||
@@ -61,12 +61,12 @@ public class Megaphone {
|
||||
return style;
|
||||
}
|
||||
|
||||
public @StringRes int getTitle() {
|
||||
return titleRes;
|
||||
public @NonNull MegaphoneText getTitle() {
|
||||
return titleText;
|
||||
}
|
||||
|
||||
public @StringRes int getBody() {
|
||||
return bodyRes;
|
||||
public @NonNull MegaphoneText getBody() {
|
||||
return bodyText;
|
||||
}
|
||||
|
||||
public @RawRes int getLottieRes() {
|
||||
@@ -81,12 +81,12 @@ public class Megaphone {
|
||||
return imageRequest;
|
||||
}
|
||||
|
||||
public @StringRes int getButtonText() {
|
||||
return buttonTextRes;
|
||||
public @Nullable MegaphoneText getButtonText() {
|
||||
return buttonText;
|
||||
}
|
||||
|
||||
public boolean hasButton() {
|
||||
return buttonTextRes != 0;
|
||||
return buttonText != null && buttonText.hasText();
|
||||
}
|
||||
|
||||
public @Nullable EventListener getButtonClickListener() {
|
||||
@@ -97,12 +97,12 @@ public class Megaphone {
|
||||
return snoozeListener;
|
||||
}
|
||||
|
||||
public @StringRes int getSecondaryButtonText() {
|
||||
return secondaryButtonTextRes;
|
||||
public @Nullable MegaphoneText getSecondaryButtonText() {
|
||||
return secondaryButtonText;
|
||||
}
|
||||
|
||||
public boolean hasSecondaryButton() {
|
||||
return secondaryButtonTextRes != 0;
|
||||
return secondaryButtonText != null && secondaryButtonText.hasText();
|
||||
}
|
||||
|
||||
public @Nullable EventListener getSecondaryButtonClickListener() {
|
||||
@@ -119,19 +119,18 @@ public class Megaphone {
|
||||
private final Style style;
|
||||
|
||||
private boolean canSnooze;
|
||||
private int titleRes;
|
||||
private int bodyRes;
|
||||
private MegaphoneText titleText;
|
||||
private MegaphoneText bodyText;
|
||||
private int imageRes;
|
||||
private int lottieRes;
|
||||
private GlideRequest<Drawable> imageRequest;
|
||||
private int buttonTextRes;
|
||||
private MegaphoneText buttonText;
|
||||
private EventListener buttonListener;
|
||||
private EventListener snoozeListener;
|
||||
private int secondaryButtonTextRes;
|
||||
private MegaphoneText secondaryButtonText;
|
||||
private EventListener secondaryButtonListener;
|
||||
private EventListener onVisibleListener;
|
||||
|
||||
|
||||
public Builder(@NonNull Event event, @NonNull Style style) {
|
||||
this.event = event;
|
||||
this.style = style;
|
||||
@@ -144,18 +143,28 @@ public class Megaphone {
|
||||
}
|
||||
|
||||
public @NonNull Builder disableSnooze() {
|
||||
this.canSnooze = false;
|
||||
this.canSnooze = false;
|
||||
this.snoozeListener = null;
|
||||
return this;
|
||||
}
|
||||
|
||||
public @NonNull Builder setTitle(@StringRes int titleRes) {
|
||||
this.titleRes = titleRes;
|
||||
this.titleText = MegaphoneText.from(titleRes);
|
||||
return this;
|
||||
}
|
||||
|
||||
public @NonNull Builder setTitle(@Nullable String title) {
|
||||
this.titleText = MegaphoneText.from(title);
|
||||
return this;
|
||||
}
|
||||
|
||||
public @NonNull Builder setBody(@StringRes int bodyRes) {
|
||||
this.bodyRes = bodyRes;
|
||||
this.bodyText = MegaphoneText.from(bodyRes);
|
||||
return this;
|
||||
}
|
||||
|
||||
public @NonNull Builder setBody(String body) {
|
||||
this.bodyText = MegaphoneText.from(body);
|
||||
return this;
|
||||
}
|
||||
|
||||
@@ -175,13 +184,25 @@ public class Megaphone {
|
||||
}
|
||||
|
||||
public @NonNull Builder setActionButton(@StringRes int buttonTextRes, @NonNull EventListener listener) {
|
||||
this.buttonTextRes = buttonTextRes;
|
||||
this.buttonText = MegaphoneText.from(buttonTextRes);
|
||||
this.buttonListener = listener;
|
||||
return this;
|
||||
}
|
||||
|
||||
public @NonNull Builder setActionButton(@NonNull String buttonText, @NonNull EventListener listener) {
|
||||
this.buttonText = MegaphoneText.from(buttonText);
|
||||
this.buttonListener = listener;
|
||||
return this;
|
||||
}
|
||||
|
||||
public @NonNull Builder setSecondaryButton(@StringRes int secondaryButtonTextRes, @NonNull EventListener listener) {
|
||||
this.secondaryButtonTextRes = secondaryButtonTextRes;
|
||||
this.secondaryButtonText = MegaphoneText.from(secondaryButtonTextRes);
|
||||
this.secondaryButtonListener = listener;
|
||||
return this;
|
||||
}
|
||||
|
||||
public @NonNull Builder setSecondaryButton(@NonNull String secondaryButtonText, @NonNull EventListener listener) {
|
||||
this.secondaryButtonText = MegaphoneText.from(secondaryButtonText);
|
||||
this.secondaryButtonListener = listener;
|
||||
return this;
|
||||
}
|
||||
@@ -197,10 +218,14 @@ public class Megaphone {
|
||||
}
|
||||
|
||||
enum Style {
|
||||
/** Specialized style for onboarding. */
|
||||
/**
|
||||
* Specialized style for onboarding.
|
||||
*/
|
||||
ONBOARDING,
|
||||
|
||||
/** Basic bottom of the screen megaphone with optional snooze and action buttons. */
|
||||
/**
|
||||
* Basic bottom of the screen megaphone with optional snooze and action buttons.
|
||||
*/
|
||||
BASIC,
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
package org.thoughtcrime.securesms.megaphone
|
||||
|
||||
import android.content.Context
|
||||
import androidx.annotation.StringRes
|
||||
|
||||
/**
|
||||
* Allows setting of megaphone text by string resource or string literal.
|
||||
*/
|
||||
data class MegaphoneText(@StringRes private val stringRes: Int = 0, private val string: String? = null) {
|
||||
@get:JvmName("hasText") val hasText = stringRes != 0 || string != null
|
||||
|
||||
fun resolve(context: Context): String? {
|
||||
return if (stringRes != 0) context.getString(stringRes) else string
|
||||
}
|
||||
|
||||
companion object {
|
||||
@JvmStatic
|
||||
fun from(@StringRes stringRes: Int): MegaphoneText = MegaphoneText(stringRes)
|
||||
|
||||
@JvmStatic
|
||||
fun from(string: String): MegaphoneText = MegaphoneText(string = string)
|
||||
}
|
||||
}
|
||||
@@ -11,12 +11,14 @@ import androidx.annotation.WorkerThread;
|
||||
|
||||
import com.annimon.stream.Stream;
|
||||
|
||||
import org.signal.core.util.SetUtil;
|
||||
import org.signal.core.util.TranslationDetection;
|
||||
import org.signal.core.util.logging.Log;
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.badges.models.Badge;
|
||||
import org.thoughtcrime.securesms.components.settings.app.AppSettingsActivity;
|
||||
import org.thoughtcrime.securesms.database.model.MegaphoneRecord;
|
||||
import org.thoughtcrime.securesms.database.model.RemoteMegaphoneRecord;
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
|
||||
import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint;
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore;
|
||||
@@ -24,6 +26,7 @@ import org.thoughtcrime.securesms.lock.SignalPinReminderDialog;
|
||||
import org.thoughtcrime.securesms.lock.SignalPinReminders;
|
||||
import org.thoughtcrime.securesms.lock.v2.CreateKbsPinActivity;
|
||||
import org.thoughtcrime.securesms.lock.v2.KbsMigrationActivity;
|
||||
import org.thoughtcrime.securesms.mms.GlideApp;
|
||||
import org.thoughtcrime.securesms.notifications.NotificationChannels;
|
||||
import org.thoughtcrime.securesms.profiles.AvatarHelper;
|
||||
import org.thoughtcrime.securesms.profiles.manage.ManageProfileActivity;
|
||||
@@ -31,7 +34,6 @@ import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
import org.thoughtcrime.securesms.util.FeatureFlags;
|
||||
import org.thoughtcrime.securesms.util.LocaleFeatureFlags;
|
||||
import org.thoughtcrime.securesms.util.PlayServicesUtil;
|
||||
import org.signal.core.util.SetUtil;
|
||||
import org.thoughtcrime.securesms.util.VersionTracker;
|
||||
import org.thoughtcrime.securesms.util.dynamiclanguage.DynamicLanguageContextWrapper;
|
||||
import org.thoughtcrime.securesms.wallpaper.ChatWallpaperActivity;
|
||||
@@ -106,6 +108,7 @@ public final class Megaphones {
|
||||
put(Event.ONBOARDING, shouldShowOnboardingMegaphone(context) ? ALWAYS : NEVER);
|
||||
put(Event.TURN_OFF_CENSORSHIP_CIRCUMVENTION, shouldShowTurnOffCircumventionMegaphone() ? RecurringSchedule.every(TimeUnit.DAYS.toMillis(7)) : NEVER);
|
||||
put(Event.DONATE_Q2_2022, shouldShowDonateMegaphone(context, Event.DONATE_Q2_2022, records) ? ShowForDurationSchedule.showForDays(7) : NEVER);
|
||||
put(Event.REMOTE_MEGAPHONE, shouldShowRemoteMegaphone(records) ? RecurringSchedule.every(TimeUnit.DAYS.toMillis(3)) : NEVER);
|
||||
put(Event.PIN_REMINDER, new SignalPinReminderSchedule());
|
||||
|
||||
// Feature-introduction megaphones should *probably* be added below this divider
|
||||
@@ -133,6 +136,8 @@ public final class Megaphones {
|
||||
return buildDonateQ2Megaphone(context);
|
||||
case TURN_OFF_CENSORSHIP_CIRCUMVENTION:
|
||||
return buildTurnOffCircumventionMegaphone(context);
|
||||
case REMOTE_MEGAPHONE:
|
||||
return buildRemoteMegaphone(context);
|
||||
default:
|
||||
throw new IllegalArgumentException("Event not handled!");
|
||||
}
|
||||
@@ -292,6 +297,44 @@ public final class Megaphones {
|
||||
.build();
|
||||
}
|
||||
|
||||
private static @NonNull Megaphone buildRemoteMegaphone(@NonNull Context context) {
|
||||
RemoteMegaphoneRecord record = RemoteMegaphoneRepository.getRemoteMegaphoneToShow();
|
||||
|
||||
if (record != null) {
|
||||
Megaphone.Builder builder = new Megaphone.Builder(Event.REMOTE_MEGAPHONE, Megaphone.Style.BASIC)
|
||||
.setTitle(record.getTitle())
|
||||
.setBody(record.getBody());
|
||||
|
||||
if (record.getImageUri() != null) {
|
||||
builder.setImageRequest(GlideApp.with(context).asDrawable().load(record.getImageUri()));
|
||||
}
|
||||
|
||||
if (record.hasPrimaryAction()) {
|
||||
//noinspection ConstantConditions
|
||||
builder.setActionButton(record.getPrimaryActionText(), (megaphone, controller) -> {
|
||||
RemoteMegaphoneRepository.getAction(Objects.requireNonNull(record.getPrimaryActionId()))
|
||||
.run(context, controller, record);
|
||||
});
|
||||
}
|
||||
|
||||
if (record.hasSecondaryAction()) {
|
||||
//noinspection ConstantConditions
|
||||
builder.setSecondaryButton(record.getSecondaryActionText(), (megaphone, controller) -> {
|
||||
RemoteMegaphoneRepository.getAction(Objects.requireNonNull(record.getSecondaryActionId()))
|
||||
.run(context, controller, record);
|
||||
});
|
||||
}
|
||||
|
||||
builder.setOnVisibleListener((megaphone, controller) -> {
|
||||
RemoteMegaphoneRepository.markShown(record.getUuid());
|
||||
});
|
||||
|
||||
return builder.build();
|
||||
} else {
|
||||
throw new IllegalStateException("No record to show");
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean shouldShowDonateMegaphone(@NonNull Context context, @NonNull Event event, @NonNull Map<Event, MegaphoneRecord> records) {
|
||||
long timeSinceLastDonatePrompt = timeSinceLastDonatePrompt(event, records);
|
||||
|
||||
@@ -349,6 +392,12 @@ public final class Megaphones {
|
||||
return true;
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
private static boolean shouldShowRemoteMegaphone(@NonNull Map<Event, MegaphoneRecord> records) {
|
||||
boolean canShowLocalDonate = timeSinceLastDonatePrompt(Event.REMOTE_MEGAPHONE, records) > MIN_TIME_BETWEEN_DONATE_MEGAPHONES;
|
||||
return RemoteMegaphoneRepository.hasRemoteMegaphoneToShow(canShowLocalDonate);
|
||||
}
|
||||
|
||||
/**
|
||||
* Unfortunately lastSeen is only set today upon snoozing, which never happens to donate prompts.
|
||||
* So we use firstVisible as a proxy.
|
||||
@@ -376,7 +425,8 @@ public final class Megaphones {
|
||||
ADD_A_PROFILE_PHOTO("add_a_profile_photo"),
|
||||
BECOME_A_SUSTAINER("become_a_sustainer"),
|
||||
DONATE_Q2_2022("donate_q2_2022"),
|
||||
TURN_OFF_CENSORSHIP_CIRCUMVENTION("turn_off_censorship_circumvention");
|
||||
TURN_OFF_CENSORSHIP_CIRCUMVENTION("turn_off_censorship_circumvention"),
|
||||
REMOTE_MEGAPHONE("remote_megaphone");
|
||||
|
||||
private final String key;
|
||||
|
||||
|
||||
@@ -66,16 +66,16 @@ public class PopupMegaphoneView extends FrameLayout {
|
||||
image.setVisibility(GONE);
|
||||
}
|
||||
|
||||
if (megaphone.getTitle() != 0) {
|
||||
if (megaphone.getTitle().hasText()) {
|
||||
titleText.setVisibility(VISIBLE);
|
||||
titleText.setText(megaphone.getTitle());
|
||||
titleText.setText(megaphone.getTitle().resolve(getContext()));
|
||||
} else {
|
||||
titleText.setVisibility(GONE);
|
||||
}
|
||||
|
||||
if (megaphone.getBody() != 0) {
|
||||
if (megaphone.getBody().hasText()) {
|
||||
bodyText.setVisibility(VISIBLE);
|
||||
bodyText.setText(megaphone.getBody());
|
||||
bodyText.setText(megaphone.getBody().resolve(getContext()));
|
||||
} else {
|
||||
bodyText.setVisibility(GONE);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
package org.thoughtcrime.securesms.megaphone
|
||||
|
||||
import android.app.Application
|
||||
import android.content.Context
|
||||
import androidx.annotation.AnyThread
|
||||
import androidx.annotation.WorkerThread
|
||||
import org.signal.core.util.concurrent.SignalExecutors
|
||||
import org.thoughtcrime.securesms.badges.models.Badge
|
||||
import org.thoughtcrime.securesms.components.settings.app.AppSettingsActivity
|
||||
import org.thoughtcrime.securesms.database.RemoteMegaphoneDatabase
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
import org.thoughtcrime.securesms.database.model.RemoteMegaphoneRecord
|
||||
import org.thoughtcrime.securesms.database.model.RemoteMegaphoneRecord.ActionId
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
|
||||
import org.thoughtcrime.securesms.megaphone.RemoteMegaphoneRepository.Action
|
||||
import org.thoughtcrime.securesms.providers.BlobProvider
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.util.LocaleFeatureFlags
|
||||
import org.thoughtcrime.securesms.util.PlayServicesUtil
|
||||
import org.thoughtcrime.securesms.util.VersionTracker
|
||||
import java.util.Objects
|
||||
|
||||
/**
|
||||
* Access point for interacting with Remote Megaphones.
|
||||
*/
|
||||
object RemoteMegaphoneRepository {
|
||||
|
||||
private val db: RemoteMegaphoneDatabase = SignalDatabase.remoteMegaphones
|
||||
private val context: Application = ApplicationDependencies.getApplication()
|
||||
|
||||
private val snooze: Action = Action { _, controller, _ -> controller.onMegaphoneSnooze(Megaphones.Event.REMOTE_MEGAPHONE) }
|
||||
|
||||
private val finish: Action = Action { context, controller, remote ->
|
||||
if (remote.imageUri != null) {
|
||||
BlobProvider.getInstance().delete(context, remote.imageUri)
|
||||
}
|
||||
controller.onMegaphoneSnooze(Megaphones.Event.REMOTE_MEGAPHONE)
|
||||
db.markFinished(remote.uuid)
|
||||
}
|
||||
|
||||
private val donate: Action = Action { context, controller, remote ->
|
||||
controller.onMegaphoneNavigationRequested(AppSettingsActivity.subscriptions(context))
|
||||
finish.run(context, controller, remote)
|
||||
}
|
||||
|
||||
private val actions = mapOf(
|
||||
ActionId.SNOOZE.id to snooze,
|
||||
ActionId.FINISH.id to finish,
|
||||
ActionId.DONATE.id to donate
|
||||
)
|
||||
|
||||
@WorkerThread
|
||||
@JvmStatic
|
||||
fun hasRemoteMegaphoneToShow(canShowLocalDonate: Boolean): Boolean {
|
||||
val record = getRemoteMegaphoneToShow()
|
||||
|
||||
return if (record == null) {
|
||||
false
|
||||
} else if (record.primaryActionId?.isDonateAction == true) {
|
||||
canShowLocalDonate
|
||||
} else {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
@JvmStatic
|
||||
fun getRemoteMegaphoneToShow(): RemoteMegaphoneRecord? {
|
||||
return db.getPotentialMegaphonesAndClearOld()
|
||||
.asSequence()
|
||||
.filter { it.imageUrl == null || it.imageUri != null }
|
||||
.filter { it.countries == null || LocaleFeatureFlags.shouldShowReleaseNote(it.uuid, it.countries) }
|
||||
.filter { it.conditionalId == null || checkCondition(it.conditionalId) }
|
||||
.firstOrNull()
|
||||
}
|
||||
|
||||
@AnyThread
|
||||
@JvmStatic
|
||||
fun getAction(action: ActionId): Action {
|
||||
return actions[action.id] ?: finish
|
||||
}
|
||||
|
||||
@AnyThread
|
||||
@JvmStatic
|
||||
fun markShown(uuid: String) {
|
||||
SignalExecutors.BOUNDED_IO.execute {
|
||||
db.markShown(uuid)
|
||||
}
|
||||
}
|
||||
|
||||
private fun checkCondition(conditionalId: String): Boolean {
|
||||
return when (conditionalId) {
|
||||
"standard_donate" -> shouldShowDonateMegaphone()
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
|
||||
private fun shouldShowDonateMegaphone(): Boolean {
|
||||
return VersionTracker.getDaysSinceFirstInstalled(context) >= 7 &&
|
||||
PlayServicesUtil.getPlayServicesStatus(context) == PlayServicesUtil.PlayServicesStatus.SUCCESS &&
|
||||
Recipient.self()
|
||||
.badges
|
||||
.stream()
|
||||
.filter { obj: Badge? -> Objects.nonNull(obj) }
|
||||
.noneMatch { (_, category): Badge -> category === Badge.Category.Donor }
|
||||
}
|
||||
|
||||
fun interface Action {
|
||||
fun run(context: Context, controller: MegaphoneActionController, remoteMegaphone: RemoteMegaphoneRecord)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user