commit 064e8c61a0bf311c6af605d003ec07c7f0f62838
Author: JabJab <noreply@xmpp.tel>
Date: Thu Sep 10 09:43:04 2026 +0300
Added reaction notifications
---
build.gradle | 2 +-
src/main/java/tel/xmpp/jabjab/AppSettings.java | 5 ++
.../xmpp/jabjab/services/NotificationService.java | 43 +++++++++++++
.../tel/xmpp/jabjab/ui/ConversationFragment.java | 13 ++++
.../jabjab/ui/ConversationsOverviewFragment.java | 17 +++++
.../xmpp/jabjab/ui/activity/SettingsActivity.java | 12 ++++
.../settings/NotificationsSettingsFragment.java | 1 +
.../fragment/settings/PrivacySettingsFragment.java | 69 ---------------------
.../fragment/settings/XmppPreferenceFragment.java | 72 ++++++++++++++++++++++
.../xmpp/jabjab/xmpp/manager/ReactionManager.java | 20 ++++++
src/main/res/values/bools.xml | 1 +
src/main/res/values/strings.xml | 4 ++
src/main/res/xml/preferences_notifications.xml | 5 ++
13 files changed, 194 insertions(+), 70 deletions(-)
diff --git a/build.gradle b/build.gradle
index 813018b..c1bdbc0 100644
--- a/build.gradle
+++ b/build.gradle
@@ -113,7 +113,7 @@ android {
defaultConfig {
minSdkVersion 23
- versionCode 42313
+ versionCode 42314
versionName "1.0.7"
applicationId "tel.xmpp.jabjab"
resValue "string", "applicationId", applicationId
diff --git a/src/main/java/tel/xmpp/jabjab/AppSettings.java b/src/main/java/tel/xmpp/jabjab/AppSettings.java
index a4c48a5..2590fe8 100644
--- a/src/main/java/tel/xmpp/jabjab/AppSettings.java
+++ b/src/main/java/tel/xmpp/jabjab/AppSettings.java
@@ -37,6 +37,7 @@ public class AppSettings {
public static final String GRACE_PERIOD_LENGTH = "grace_period_length";
public static final String MANUALLY_CHANGE_PRESENCE = "manually_change_presence";
public static final String HIDE_NOTIFICATION_CONTENT = "hide_notification_content";
+ public static final String NOTIFY_ON_REACTION = "notify_on_reaction";
public static final String BLIND_TRUST_BEFORE_VERIFICATION = "btbv";
public static final String AUTOMATIC_MESSAGE_DELETION = "automatic_message_deletion";
public static final String BROADCAST_LAST_ACTIVITY = "last_activity";
@@ -176,6 +177,10 @@ public class AppSettings {
return getBooleanPreference(HIDE_NOTIFICATION_CONTENT, R.bool.hide_notification_content);
}
+ public boolean isNotifyOnReaction() {
+ return getBooleanPreference(NOTIFY_ON_REACTION, R.bool.notify_on_reaction);
+ }
+
public boolean isQuickActionRecordingAuto() {
return isAutoSendRecording() && getQuickAction() == AttachmentChoice.Type.RECORDING;
}
diff --git a/src/main/java/tel/xmpp/jabjab/services/NotificationService.java b/src/main/java/tel/xmpp/jabjab/services/NotificationService.java
index 78bf6b2..202c9b9 100644
--- a/src/main/java/tel/xmpp/jabjab/services/NotificationService.java
+++ b/src/main/java/tel/xmpp/jabjab/services/NotificationService.java
@@ -113,6 +113,7 @@ public class NotificationService {
NOTIFICATION_ID_MULTIPLIER * 14;
private static final int NEW_STORY_NOTIFICATION_ID = NOTIFICATION_ID_MULTIPLIER * 16;
private static final int NEW_BLOG_POST_NOTIFICATION_ID = NOTIFICATION_ID_MULTIPLIER * 18;
+ private static final int REACTION_NOTIFICATION_ID = NOTIFICATION_ID_MULTIPLIER * 20;
private final XmppConnectionService mXmppConnectionService;
private final LinkedHashMap<String, ArrayList<Message>> notifications = new LinkedHashMap<>();
private final HashMap<Conversation, AtomicInteger> mBacklogMessageCounter = new HashMap<>();
@@ -660,6 +661,31 @@ public class NotificationService {
notify(NEW_BLOG_POST_NOTIFICATION_ID + Math.abs(itemId.hashCode() % 1000), notification);
}
+ /** Called by ReactionManager when someone reacts to a message we authored. */
+ public void pushReactionNotification(
+ final Message message, final String reactorName, final String emoji) {
+ if (!new AppSettings(mXmppConnectionService).isNotifyOnReaction()) {
+ return;
+ }
+ final Conversational conversation = message.getConversation();
+ final PendingIntent pendingIntent =
+ createScrollToMessageIntent(conversation.getUuid(), message.getUuid());
+ final String contentText =
+ mXmppConnectionService.getString(
+ R.string.reaction_notification_text, emoji, notificationBody(message));
+ final Notification notification =
+ new Builder(mXmppConnectionService, MESSAGES_NOTIFICATION_CHANNEL)
+ .setSmallIcon(R.drawable.ic_app_icon_notification)
+ .setContentTitle(reactorName)
+ .setContentText(contentText)
+ .setAutoCancel(true)
+ .setContentIntent(pendingIntent)
+ .build();
+ notify(
+ REACTION_NOTIFICATION_ID + Math.abs(message.getUuid().hashCode() % 1000),
+ notification);
+ }
+
public synchronized void startRinging(
final AbstractJingleConnection.Id id, final Set<Media> media) {
showIncomingCallNotification(id, media, false);
@@ -1714,6 +1740,23 @@ public class NotificationService {
.first));
}
+ private PendingIntent createScrollToMessageIntent(
+ final String conversationUuid, final String messageUuid) {
+ final Intent viewConversationIntent =
+ new Intent(mXmppConnectionService, ConversationsActivity.class);
+ viewConversationIntent.setAction(ConversationsActivity.ACTION_VIEW_CONVERSATION);
+ viewConversationIntent.putExtra(ConversationsActivity.EXTRA_CONVERSATION, conversationUuid);
+ viewConversationIntent.putExtra(
+ ConversationsActivity.EXTRA_SCROLL_TO_MESSAGE, messageUuid);
+ return PendingIntent.getActivity(
+ mXmppConnectionService,
+ generateRequestCode(messageUuid, 20),
+ viewConversationIntent,
+ s()
+ ? PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_UPDATE_CURRENT
+ : PendingIntent.FLAG_UPDATE_CURRENT);
+ }
+
private PendingIntent createShowLocationIntent(final Message message) {
final Intent intent;
try {
diff --git a/src/main/java/tel/xmpp/jabjab/ui/ConversationFragment.java b/src/main/java/tel/xmpp/jabjab/ui/ConversationFragment.java
index 1031c99..160c914 100644
--- a/src/main/java/tel/xmpp/jabjab/ui/ConversationFragment.java
+++ b/src/main/java/tel/xmpp/jabjab/ui/ConversationFragment.java
@@ -4658,7 +4658,19 @@ public class ConversationFragment extends XmppFragment
}
}
+ /** Stops any in-flight "pin to bottom" behavior so an explicit jump to an
+ * arbitrary message isn't immediately overridden by the next UI refresh
+ * treating pinToBottom as still true (see activatePinToBottom / refresh). */
+ private void deactivatePinToBottom() {
+ pinToBottom = false;
+ pinToBottomHandler.removeCallbacks(cancelPinToBottom);
+ if (binding != null) {
+ binding.messagesView.getViewTreeObserver().removeOnGlobalLayoutListener(pinToBottomListener);
+ }
+ }
+
private void scrollToMessage(final String uuid) {
+ deactivatePinToBottom();
final int pos = getIndexOf(uuid, this.messageList);
if (pos >= 0) {
setSelection(pos, false);
@@ -4682,6 +4694,7 @@ public class ConversationFragment extends XmppFragment
conversation, 100, msg.getTimeSent());
runOnUiThread(
() -> {
+ deactivatePinToBottom();
conversation.addAll(0, older);
conversation.addAll(
older.size(), Collections.singletonList(msg));
diff --git a/src/main/java/tel/xmpp/jabjab/ui/ConversationsOverviewFragment.java b/src/main/java/tel/xmpp/jabjab/ui/ConversationsOverviewFragment.java
index 7b4b26f..a6a7784 100644
--- a/src/main/java/tel/xmpp/jabjab/ui/ConversationsOverviewFragment.java
+++ b/src/main/java/tel/xmpp/jabjab/ui/ConversationsOverviewFragment.java
@@ -413,6 +413,7 @@ public class ConversationsOverviewFragment extends XmppFragment {
(view) -> StartConversationActivity.launch(getActivity()));
showTranslationFeatureBanner();
showAnimatedBannerFeatureBanner();
+ showReactionNotificationsFeatureBanner();
this.conversationsAdapter =
new ConversationAdapter(requireXmppActivity(), this.conversations);
@@ -461,6 +462,22 @@ public class ConversationsOverviewFragment extends XmppFragment {
() -> AccountUtils.launchEditProfile(requireXmppActivity()));
}
+ private void showReactionNotificationsFeatureBanner() {
+ this.binding.featureBanner.registerBanner(
+ "reaction_notifications_feature",
+ R.drawable.ic_add_reaction_24dp,
+ getString(R.string.reaction_notifications_feature_banner_text),
+ () -> {
+ final var intent = new Intent(requireContext(), SettingsActivity.class);
+ intent.putExtra(
+ SettingsActivity.EXTRA_OPEN_FRAGMENT,
+ SettingsActivity.FRAGMENT_NOTIFICATIONS);
+ intent.putExtra(
+ SettingsActivity.EXTRA_SCROLL_TO_PREFERENCE, "notify_on_reaction");
+ startActivity(intent);
+ });
+ }
+
private void startSearch(final String term) {
final var intent = new Intent(requireContext(), SearchActivity.class);
intent.putExtra(SearchActivity.EXTRA_SEARCH_TERM, term);
diff --git a/src/main/java/tel/xmpp/jabjab/ui/activity/SettingsActivity.java b/src/main/java/tel/xmpp/jabjab/ui/activity/SettingsActivity.java
index 94dcbae..0a8f36c 100644
--- a/src/main/java/tel/xmpp/jabjab/ui/activity/SettingsActivity.java
+++ b/src/main/java/tel/xmpp/jabjab/ui/activity/SettingsActivity.java
@@ -25,6 +25,7 @@ public class SettingsActivity extends XmppActivity {
// the "new translation feature" announcement banner on the conversations overview.
public static final String EXTRA_OPEN_FRAGMENT = "open_fragment";
public static final String FRAGMENT_PRIVACY = "privacy";
+ public static final String FRAGMENT_NOTIFICATIONS = "notifications";
// Optional: once landed on that sub-screen, scroll straight to a specific preference key
// (e.g. "translation_backend_priority") instead of leaving the user at the top of the list.
public static final String EXTRA_SCROLL_TO_PREFERENCE = "scroll_to_preference";
@@ -66,6 +67,17 @@ public class SettingsActivity extends XmppActivity {
fragment.setArguments(args);
}
preferenceFragment = fragment;
+ } else if (FRAGMENT_NOTIFICATIONS.equals(
+ intent == null ? null : intent.getStringExtra(EXTRA_OPEN_FRAGMENT))) {
+ final var fragment = new NotificationsSettingsFragment();
+ final String scrollTo =
+ intent == null ? null : intent.getStringExtra(EXTRA_SCROLL_TO_PREFERENCE);
+ if (scrollTo != null) {
+ final Bundle args = new Bundle();
+ args.putString(EXTRA_SCROLL_TO_PREFERENCE, scrollTo);
+ fragment.setArguments(args);
+ }
+ preferenceFragment = fragment;
} else {
preferenceFragment = new MainSettingsFragment();
}
diff --git a/src/main/java/tel/xmpp/jabjab/ui/fragment/settings/NotificationsSettingsFragment.java b/src/main/java/tel/xmpp/jabjab/ui/fragment/settings/NotificationsSettingsFragment.java
index 8e8fb89..31a7cca 100644
--- a/src/main/java/tel/xmpp/jabjab/ui/fragment/settings/NotificationsSettingsFragment.java
+++ b/src/main/java/tel/xmpp/jabjab/ui/fragment/settings/NotificationsSettingsFragment.java
@@ -137,6 +137,7 @@ public class NotificationsSettingsFragment extends XmppPreferenceFragment {
public void onStart() {
super.onStart();
requireActivity().setTitle(R.string.notifications);
+ scrollToRequestedPreference();
}
@Override
diff --git a/src/main/java/tel/xmpp/jabjab/ui/fragment/settings/PrivacySettingsFragment.java b/src/main/java/tel/xmpp/jabjab/ui/fragment/settings/PrivacySettingsFragment.java
index 5181c50..0a74ae6 100644
--- a/src/main/java/tel/xmpp/jabjab/ui/fragment/settings/PrivacySettingsFragment.java
+++ b/src/main/java/tel/xmpp/jabjab/ui/fragment/settings/PrivacySettingsFragment.java
@@ -25,10 +25,6 @@ import java.util.stream.Collectors;
public class PrivacySettingsFragment extends XmppPreferenceFragment {
- // Scrolling is a one-shot: only do it the first time this fragment starts, not on every
- // onStart() (e.g. returning from a picker dialog shouldn't re-jump the list).
- private boolean didScrollToRequestedPreference = false;
-
@Override
public void onCreatePreferences(@Nullable Bundle savedInstanceState, @Nullable String rootKey) {
setPreferencesFromResource(R.xml.preferences_privacy, rootKey);
@@ -45,71 +41,6 @@ public class PrivacySettingsFragment extends XmppPreferenceFragment {
scrollToRequestedPreference();
}
- private void scrollToRequestedPreference() {
- if (didScrollToRequestedPreference) return;
- final Bundle args = getArguments();
- final String key = args == null
- ? null
- : args.getString(
- tel.xmpp.jabjab.ui.activity.SettingsActivity.EXTRA_SCROLL_TO_PREFERENCE);
- if (key == null) return;
- didScrollToRequestedPreference = true;
- scrollToPreference(key);
- // Same smooth "blink" used for jumping to a reply's original message
- // (ConversationFragment.highlightMessageAt) — scrolling alone can be easy to miss,
- // this makes it obvious exactly which row the user was jumped to.
- final androidx.recyclerview.widget.RecyclerView recyclerView = getListView();
- if (recyclerView != null) {
- recyclerView.post(() -> highlightPreferenceView(recyclerView, key, 2));
- }
- }
-
- private void highlightPreferenceView(
- final androidx.recyclerview.widget.RecyclerView recyclerView,
- final String key,
- final int retriesLeft) {
- final var adapter = recyclerView.getAdapter();
- if (!(adapter instanceof androidx.preference.PreferenceGroup.PreferencePositionCallback
- callback)) {
- return;
- }
- final int position = callback.getPreferenceAdapterPosition(key);
- if (position < 0) return;
- final var holder = recyclerView.findViewHolderForAdapterPosition(position);
- if (holder == null) {
- // The scrolled-to row may not be laid out on the very first frame after
- // scrollToPreference() — give it one more frame before giving up.
- if (retriesLeft > 0) {
- recyclerView.post(() -> highlightPreferenceView(recyclerView, key, retriesLeft - 1));
- }
- return;
- }
- flashHighlight(holder.itemView);
- }
-
- private void flashHighlight(final android.view.View view) {
- final int highlightColor =
- com.google.android.material.color.MaterialColors.getColor(
- view, androidx.appcompat.R.attr.colorPrimary);
- final android.graphics.drawable.ColorDrawable overlay =
- new android.graphics.drawable.ColorDrawable(highlightColor);
- overlay.setAlpha(0);
- view.setForeground(overlay);
- final android.animation.ValueAnimator animator =
- android.animation.ValueAnimator.ofInt(0, 90, 90, 0);
- animator.setDuration(2200);
- animator.setInterpolator(new android.view.animation.AccelerateDecelerateInterpolator());
- animator.addUpdateListener(a -> overlay.setAlpha((int) a.getAnimatedValue()));
- animator.addListener(
- new android.animation.AnimatorListenerAdapter() {
- @Override
- public void onAnimationEnd(final android.animation.Animator animation) {
- view.setForeground(null);
- }
- });
- animator.start();
- }
-
private static final String OFFLINE_TRANSLATOR_RELEASES_API =
"https://api.github.com/repos/DavidVentura/offline-translator/releases/latest";
private static final String OFFLINE_TRANSLATOR_RELEASES_PAGE =
diff --git a/src/main/java/tel/xmpp/jabjab/ui/fragment/settings/XmppPreferenceFragment.java b/src/main/java/tel/xmpp/jabjab/ui/fragment/settings/XmppPreferenceFragment.java
index c182b2e..173190a 100644
--- a/src/main/java/tel/xmpp/jabjab/ui/fragment/settings/XmppPreferenceFragment.java
+++ b/src/main/java/tel/xmpp/jabjab/ui/fragment/settings/XmppPreferenceFragment.java
@@ -24,6 +24,10 @@ import tel.xmpp.jabjab.utils.TimeFrameUtils;
public abstract class XmppPreferenceFragment extends PreferenceFragmentCompat {
+ // Scrolling is a one-shot: only do it the first time this fragment starts, not on every
+ // onStart() (e.g. returning from a picker dialog shouldn't re-jump the list).
+ private boolean didScrollToRequestedPreference = false;
+
private final SharedPreferences.OnSharedPreferenceChangeListener
sharedPreferenceChangeListener =
(sharedPreferences, key) -> {
@@ -103,6 +107,74 @@ public abstract class XmppPreferenceFragment extends PreferenceFragmentCompat {
}
}
+ /** Deep-link support: jumps to and briefly highlights a preference row named by
+ * {@link tel.xmpp.jabjab.ui.activity.SettingsActivity#EXTRA_SCROLL_TO_PREFERENCE} in this
+ * fragment's arguments, if present. Call from onStart(). */
+ protected void scrollToRequestedPreference() {
+ if (didScrollToRequestedPreference) return;
+ final var args = getArguments();
+ final String key = args == null
+ ? null
+ : args.getString(
+ tel.xmpp.jabjab.ui.activity.SettingsActivity.EXTRA_SCROLL_TO_PREFERENCE);
+ if (key == null) return;
+ didScrollToRequestedPreference = true;
+ scrollToPreference(key);
+ // Same smooth "blink" used for jumping to a reply's original message
+ // (ConversationFragment.highlightMessageAt) — scrolling alone can be easy to miss,
+ // this makes it obvious exactly which row the user was jumped to.
+ final var recyclerView = getListView();
+ if (recyclerView != null) {
+ recyclerView.post(() -> highlightPreferenceView(recyclerView, key, 2));
+ }
+ }
+
+ private void highlightPreferenceView(
+ final androidx.recyclerview.widget.RecyclerView recyclerView,
+ final String key,
+ final int retriesLeft) {
+ final var adapter = recyclerView.getAdapter();
+ if (!(adapter instanceof androidx.preference.PreferenceGroup.PreferencePositionCallback
+ callback)) {
+ return;
+ }
+ final int position = callback.getPreferenceAdapterPosition(key);
+ if (position < 0) return;
+ final var holder = recyclerView.findViewHolderForAdapterPosition(position);
+ if (holder == null) {
+ // The scrolled-to row may not be laid out on the very first frame after
+ // scrollToPreference() — give it one more frame before giving up.
+ if (retriesLeft > 0) {
+ recyclerView.post(() -> highlightPreferenceView(recyclerView, key, retriesLeft - 1));
+ }
+ return;
+ }
+ flashHighlight(holder.itemView);
+ }
+
+ private void flashHighlight(final android.view.View view) {
+ final int highlightColor =
+ com.google.android.material.color.MaterialColors.getColor(
+ view, androidx.appcompat.R.attr.colorPrimary);
+ final android.graphics.drawable.ColorDrawable overlay =
+ new android.graphics.drawable.ColorDrawable(highlightColor);
+ overlay.setAlpha(0);
+ view.setForeground(overlay);
+ final android.animation.ValueAnimator animator =
+ android.animation.ValueAnimator.ofInt(0, 90, 90, 0);
+ animator.setDuration(2200);
+ animator.setInterpolator(new android.view.animation.AccelerateDecelerateInterpolator());
+ animator.addUpdateListener(a -> overlay.setAlpha((int) a.getAnimatedValue()));
+ animator.addListener(
+ new android.animation.AnimatorListenerAdapter() {
+ @Override
+ public void onAnimationEnd(final android.animation.Animator animation) {
+ view.setForeground(null);
+ }
+ });
+ animator.start();
+ }
+
protected void setValues(
final ListPreference listPreference,
@ArrayRes int resId,
diff --git a/src/main/java/tel/xmpp/jabjab/xmpp/manager/ReactionManager.java b/src/main/java/tel/xmpp/jabjab/xmpp/manager/ReactionManager.java
index eb07b91..6a3c9f0 100644
--- a/src/main/java/tel/xmpp/jabjab/xmpp/manager/ReactionManager.java
+++ b/src/main/java/tel/xmpp/jabjab/xmpp/manager/ReactionManager.java
@@ -74,6 +74,11 @@ public class ReactionManager extends AbstractManager {
message.setReactions(combinedReactions);
this.getDatabase().updateMessage(message, false);
this.service.updateConversationUi();
+ if (isReceived && message.getStatus() != Message.STATUS_RECEIVED) {
+ final String reactorName =
+ user == null ? counterpart.getResource() : user.getDisplayName();
+ notifyReaction(message, reactorName, reactions.getReactions());
+ }
} else {
Log.d(Config.LOGTAG, "message with id " + reactingTo + " not found");
}
@@ -136,9 +141,24 @@ public class ReactionManager extends AbstractManager {
reactionFrom);
message.setReactions(combinedReactions);
this.service.updateMessage(message, false);
+ if (isReceived && message.getStatus() != Message.STATUS_RECEIVED) {
+ final String reactorName;
+ if (conversation.getMode() == Conversational.MODE_MULTI) {
+ reactorName = reactionFrom.getResource();
+ } else {
+ reactorName = account.getRoster().getContact(reactionFrom).getDisplayName();
+ }
+ notifyReaction(message, reactorName, reactions.getReactions());
+ }
}
}
+ private void notifyReaction(
+ final Message message, final String reactorName, final Collection<String> emojis) {
+ final String emoji = emojis.stream().findFirst().orElse("");
+ this.service.getNotificationService().pushReactionNotification(message, reactorName, emoji);
+ }
+
public boolean sendReactions(final Message message, final Collection<String> reactions) {
final Conversation conversation;
if (message.getConversation() instanceof Conversation c) {
diff --git a/src/main/res/values/bools.xml b/src/main/res/values/bools.xml
index 48c39b2..c3e3eb9 100644
--- a/src/main/res/values/bools.xml
+++ b/src/main/res/values/bools.xml
@@ -2,5 +2,6 @@
<resources>
<bool name="is_portrait_mode">true</bool>
<bool name="hide_notification_content">false</bool>
+ <bool name="notify_on_reaction">true</bool>
<bool name="link_preview_default">true</bool>
</resources>
\ No newline at end of file
diff --git a/src/main/res/values/strings.xml b/src/main/res/values/strings.xml
index ad963a9..02a098b 100644
--- a/src/main/res/values/strings.xml
+++ b/src/main/res/values/strings.xml
@@ -20,6 +20,7 @@
<string name="ephemeral_timer_disabled">Disappearing messages turned off</string>
<string name="notification_ephemeral_message">🔥 Disappearing message</string>
<string name="notification_new_message">New message</string>
+ <string name="reaction_notification_text">reacted %1$s to \"%2$s\"</string>
<string name="pref_category_notifications">Notifications</string>
<string name="pref_hide_notification_content">Hide message content in notifications</string>
<string name="pref_hide_notification_content_summary">Show \"New message\" instead of the actual text. Enabled automatically when app lock is on.</string>
@@ -793,6 +794,8 @@
<string name="pref_notify_new_stories_summary">Notify when a contact posts a new story.</string>
<string name="pref_notify_new_blog_posts">New blog posts</string>
<string name="pref_notify_new_blog_posts_summary">Notify when a contact publishes a new blog post.</string>
+ <string name="pref_notify_on_reaction">Reaction notifications</string>
+ <string name="pref_notify_on_reaction_summary">Notify when someone reacts to one of your messages.</string>
<string name="received_message_from_stranger">Received message from stranger</string>
<string name="block_stranger">Block stranger</string>
<string name="block_entire_domain">Block entire domain</string>
@@ -1582,6 +1585,7 @@
<string name="offline_translator_apk_not_found">Couldn\'t find an APK in the latest release — opening the releases page instead</string>
<string name="translation_feature_banner_text">New On-Device offline translation feature added. Tap here to find out more and how to set it up.</string>
<string name="animated_banner_feature_banner_text">Profile banners can now be animated (GIF/WebP). Tap here to set one up.</string>
+ <string name="reaction_notifications_feature_banner_text">You now get notified when someone reacts to your messages. Tap here to manage this.</string>
<string name="dismiss">Dismiss</string>
<string name="translation_backend_private">Private (Qwen3.6)</string>
<string name="translation_backend_offline_device">On-device (offline-translator app)</string>
diff --git a/src/main/res/xml/preferences_notifications.xml b/src/main/res/xml/preferences_notifications.xml
index 51d153f..47d0380 100644
--- a/src/main/res/xml/preferences_notifications.xml
+++ b/src/main/res/xml/preferences_notifications.xml
@@ -79,6 +79,11 @@
android:key="notify_new_blog_posts"
android:summary="@string/pref_notify_new_blog_posts_summary"
android:title="@string/pref_notify_new_blog_posts" />
+ <SwitchPreferenceCompat
+ android:defaultValue="@bool/notify_on_reaction"
+ android:key="notify_on_reaction"
+ android:summary="@string/pref_notify_on_reaction_summary"
+ android:title="@string/pref_notify_on_reaction" />
<SwitchPreferenceCompat
android:defaultValue="@bool/enable_foreground_service"
Built with passion for open-source software, XMPP, privacy, and security —
the belief that people deserve communication tools that serve them, not surveil them.
Dedicated to every brave man and woman who stands up against tyranny and oppression across the world.
And in memory of those who were silenced before they could speak freely.