🔀 Commit

Notifications for new Stories and Blog posts. Updater fixed
Commitd118d4d1930b42a59569008b1b21eac80ec062d0
AuthorJabJab <noreply@xmpp.tel>
Date2026-07-19
Parent6ebdc092
commit d118d4d1930b42a59569008b1b21eac80ec062d0
Author: JabJab <noreply@xmpp.tel>
Date:   Sun Jul 19 15:25:36 2026 +0300

    Notifications for new Stories and Blog posts. Updater fixed
---
 .../xmpp/processor/AccountStateProcessor.java      |   5 +
 src/main/java/tel/xmpp/jabjab/AppSettings.java     |  10 ++
 .../xmpp/jabjab/persistance/DatabaseBackend.java   |  23 +++++
 .../xmpp/jabjab/services/NotificationService.java  |  93 +++++++++++++++++
 .../jabjab/services/XmppConnectionService.java     |  45 +++++++-
 src/main/java/tel/xmpp/jabjab/ui/BlogActivity.java |  18 +++-
 .../java/tel/xmpp/jabjab/ui/BlogPostActivity.java  | 114 ++++++++++++++++++++-
 .../tel/xmpp/jabjab/ui/ConversationsActivity.java  |  16 +++
 .../java/tel/xmpp/jabjab/ui/StoriesActivity.java   |  31 +++++-
 src/main/java/tel/xmpp/jabjab/ui/XmppActivity.java |  16 +++
 .../xmpp/jabjab/ui/util/BlogNotifiedTracker.java   |  59 +++++++++++
 .../tel/xmpp/jabjab/ui/util/UpdateChecker.java     |   6 ++
 .../tel/xmpp/jabjab/xmpp/manager/BlogManager.java  |  89 ++++++++++++++++
 .../tel/xmpp/jabjab/xmpp/manager/DiscoManager.java |   6 ++
 .../xmpp/jabjab/xmpp/manager/StoriesManager.java   |  44 ++++++++
 src/main/res/values-ar/strings.xml                 |  10 ++
 src/main/res/values-cs/strings.xml                 |  10 ++
 src/main/res/values-da-rDK/strings.xml             |  10 ++
 src/main/res/values-de/strings.xml                 |  10 ++
 src/main/res/values-es/strings.xml                 |  10 ++
 src/main/res/values-et/strings.xml                 |  10 ++
 src/main/res/values-fi/strings.xml                 |  10 ++
 src/main/res/values-fr/strings.xml                 |  10 ++
 src/main/res/values-it/strings.xml                 |  10 ++
 src/main/res/values-ja/strings.xml                 |  10 ++
 src/main/res/values-nl/strings.xml                 |  10 ++
 src/main/res/values-pl/strings.xml                 |  10 ++
 src/main/res/values-pt-rBR/strings.xml             |  10 ++
 src/main/res/values-pt/strings.xml                 |  10 ++
 src/main/res/values-ro-rRO/strings.xml             |  10 ++
 src/main/res/values-ru/strings.xml                 |  10 ++
 src/main/res/values-sr/strings.xml                 |  10 ++
 src/main/res/values-sv/strings.xml                 |  10 ++
 src/main/res/values-zh-rCN/strings.xml             |  10 ++
 src/main/res/values-zh-rTW/strings.xml             |  10 ++
 src/main/res/values/defaults.xml                   |   2 +
 src/main/res/values/strings.xml                    |  10 ++
 src/main/res/xml/preferences_notifications.xml     |  10 ++
 38 files changed, 787 insertions(+), 10 deletions(-)

diff --git a/src/main/java/im/conversations/android/xmpp/processor/AccountStateProcessor.java b/src/main/java/im/conversations/android/xmpp/processor/AccountStateProcessor.java
index cf1c8b4..858a685 100644
--- a/src/main/java/im/conversations/android/xmpp/processor/AccountStateProcessor.java
+++ b/src/main/java/im/conversations/android/xmpp/processor/AccountStateProcessor.java
@@ -71,9 +71,14 @@ public class AccountStateProcessor extends XmppConnection.Delegate
             // TO-subscribed contacts' stories so StoriesActivity can load from DB instantly.
             final StoriesManager storiesManager = getManager(StoriesManager.class);
             storiesManager.fetchAndCacheSelf();
+            // Blog has no live PEP push (see BlogManager.fetchAndNotify javadoc), so this
+            // account-online catch-up is the only way a post made while we were offline
+            // gets noticed and notified without the user manually opening BlogActivity.
+            final var blogManager = getManager(tel.xmpp.jabjab.xmpp.manager.BlogManager.class);
             for (final Contact contact : account.getRoster().getContacts()) {
                 if (contact.getOption(Contact.Options.TO)) {
                     storiesManager.fetchAndCache(contact.getAddress());
+                    blogManager.fetchAndNotify(contact.getAddress());
                 }
             }
         } else if (account.getStatus() == Account.State.OFFLINE
diff --git a/src/main/java/tel/xmpp/jabjab/AppSettings.java b/src/main/java/tel/xmpp/jabjab/AppSettings.java
index 6d60505..dcdbf7d 100644
--- a/src/main/java/tel/xmpp/jabjab/AppSettings.java
+++ b/src/main/java/tel/xmpp/jabjab/AppSettings.java
@@ -85,6 +85,8 @@ public class AppSettings {
 
     private static final String ACCEPT_INVITES_FROM_STRANGERS = "accept_invites_from_strangers";
     private static final String NOTIFICATIONS_FROM_STRANGERS = "notifications_from_strangers";
+    private static final String NOTIFY_NEW_STORIES = "notify_new_stories";
+    private static final String NOTIFY_NEW_BLOG_POSTS = "notify_new_blog_posts";
     private static final String INSTALLATION_ID = "im.conversations.android.install_id";
 
     private static final String EXTERNAL_STORAGE_AUTHORITY =
@@ -258,6 +260,14 @@ public class AppSettings {
                 NOTIFICATIONS_FROM_STRANGERS, R.bool.notifications_from_strangers);
     }
 
+    public boolean isNotifyNewStories() {
+        return getBooleanPreference(NOTIFY_NEW_STORIES, R.bool.notify_new_stories);
+    }
+
+    public boolean isNotifyNewBlogPosts() {
+        return getBooleanPreference(NOTIFY_NEW_BLOG_POSTS, R.bool.notify_new_blog_posts);
+    }
+
     public boolean isKeepForegroundService() {
         return Compatibility.twentySix()
                 || getBooleanPreference(KEEP_FOREGROUND_SERVICE, R.bool.enable_foreground_service);
diff --git a/src/main/java/tel/xmpp/jabjab/persistance/DatabaseBackend.java b/src/main/java/tel/xmpp/jabjab/persistance/DatabaseBackend.java
index 3ddd56f..5db5bfd 100644
--- a/src/main/java/tel/xmpp/jabjab/persistance/DatabaseBackend.java
+++ b/src/main/java/tel/xmpp/jabjab/persistance/DatabaseBackend.java
@@ -2845,6 +2845,29 @@ public class DatabaseBackend extends SQLiteOpenHelper {
 
     // ── Story cache ────────────────────────────────────────────────────────────
 
+    /**
+     * item_ids currently cached for a contact, queried BEFORE calling
+     * {@link #replaceStoryCacheForContact} with a fresh set — the diff between this
+     * and the incoming set is how callers determine "is this item genuinely new"
+     * (for notification purposes) without needing a separate persisted flag, which
+     * a full delete-and-reinsert-on-every-sync cache like this one can't hold onto.
+     */
+    public java.util.Set<String> getCachedStoryItemIds(
+            final String accountJid, final String contactJid) {
+        final java.util.Set<String> itemIds = new java.util.HashSet<>();
+        final SQLiteDatabase db = getReadableDatabase();
+        try (final Cursor c = db.query("story_cache",
+                new String[]{"item_id"},
+                "account_jid = ? AND contact_jid = ?",
+                new String[]{accountJid, contactJid},
+                null, null, null)) {
+            while (c.moveToNext()) {
+                itemIds.add(c.getString(0));
+            }
+        }
+        return itemIds;
+    }
+
     /**
      * Replaces all cached stories for a given (accountJid, contactJid) pair with
      * the new set, then purges entries whose published timestamp is > 24 h old.
diff --git a/src/main/java/tel/xmpp/jabjab/services/NotificationService.java b/src/main/java/tel/xmpp/jabjab/services/NotificationService.java
index 4b128e3..e14689f 100644
--- a/src/main/java/tel/xmpp/jabjab/services/NotificationService.java
+++ b/src/main/java/tel/xmpp/jabjab/services/NotificationService.java
@@ -111,6 +111,8 @@ public class NotificationService {
     private static final int DELIVERY_FAILED_NOTIFICATION_ID = NOTIFICATION_ID_MULTIPLIER * 14;
     public static final int ONGOING_VIDEO_TRANSCODING_NOTIFICATION_ID =
             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 final XmppConnectionService mXmppConnectionService;
     private final LinkedHashMap<String, ArrayList<Message>> notifications = new LinkedHashMap<>();
     private final HashMap<Conversation, AtomicInteger> mBacklogMessageCounter = new HashMap<>();
@@ -123,6 +125,8 @@ public class NotificationService {
     private static final String INCOMING_CALLS_NOTIFICATION_CHANNEL_PREFIX =
             "incoming_calls_channel#";
     public static final String MESSAGES_NOTIFICATION_CHANNEL = "messages";
+    private static final String NEW_STORIES_CHANNEL = "new_stories";
+    private static final String NEW_BLOG_POSTS_CHANNEL = "new_blog_posts";
 
     NotificationService(final XmppConnectionService service) {
         this.mXmppConnectionService = service;
@@ -267,6 +271,24 @@ public class NotificationService {
                         .build());
         deliveryFailedChannel.setGroup("chats");
         notificationManager.createNotificationChannel(deliveryFailedChannel);
+
+        final NotificationChannel newStoriesChannel =
+                new NotificationChannel(
+                        NEW_STORIES_CHANNEL,
+                        c.getString(R.string.new_stories_channel_name),
+                        NotificationManager.IMPORTANCE_DEFAULT);
+        newStoriesChannel.setShowBadge(true);
+        newStoriesChannel.setGroup("chats");
+        notificationManager.createNotificationChannel(newStoriesChannel);
+
+        final NotificationChannel newBlogPostsChannel =
+                new NotificationChannel(
+                        NEW_BLOG_POSTS_CHANNEL,
+                        c.getString(R.string.new_blog_posts_channel_name),
+                        NotificationManager.IMPORTANCE_DEFAULT);
+        newBlogPostsChannel.setShowBadge(true);
+        newBlogPostsChannel.setGroup("chats");
+        notificationManager.createNotificationChannel(newBlogPostsChannel);
     }
 
     @RequiresApi(api = Build.VERSION_CODES.R)
@@ -567,6 +589,77 @@ public class NotificationService {
         }
     }
 
+    /** Called by StoriesManager when a roster contact publishes a genuinely new story
+     * (caller already diffed against the cache — this never re-checks "is it new"). */
+    public void pushNewStory(
+            final Account account, final tel.xmpp.jabjab.xmpp.Jid from, final String itemId,
+            final im.conversations.android.xmpp.model.stories.Story story) {
+        if (!new AppSettings(mXmppConnectionService).isNotifyNewStories()) {
+            return;
+        }
+        final var contact = account.getRoster().getContact(from);
+        final Intent intent = new Intent(mXmppConnectionService, tel.xmpp.jabjab.ui.StoriesActivity.class);
+        intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TOP);
+        intent.putExtra("account", account.getJid().asBareJid().toString());
+        intent.putExtra("highlight_item", itemId);
+        final PendingIntent pendingIntent =
+                PendingIntent.getActivity(
+                        mXmppConnectionService, itemId.hashCode(), intent,
+                        PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE);
+        final Notification notification =
+                new Builder(mXmppConnectionService, NEW_STORIES_CHANNEL)
+                        .setSmallIcon(R.drawable.ic_app_icon_notification)
+                        .setContentTitle(contact.getDisplayName())
+                        .setContentText(
+                                Strings.isNullOrEmpty(story.getCaption())
+                                        ? mXmppConnectionService.getString(R.string.new_story_notification_text)
+                                        : story.getCaption())
+                        .setAutoCancel(true)
+                        .setContentIntent(pendingIntent)
+                        .build();
+        notify(NEW_STORY_NOTIFICATION_ID + Math.abs(itemId.hashCode() % 1000), notification);
+    }
+
+    /** Called by BlogManager when a roster contact publishes a new blog post, or edits one
+     * (an edit republishes with the same itemId — see BlogManager.editPost — so isEdit
+     * distinguishes the two here for wording; caller already applied the re-notify throttle
+     * via BlogNotifiedTracker, this never re-checks "is it new/changed"). */
+    public void pushNewBlogPost(
+            final Account account, final tel.xmpp.jabjab.xmpp.Jid from, final String itemId,
+            final String title, final boolean isEdit) {
+        if (!new AppSettings(mXmppConnectionService).isNotifyNewBlogPosts()) {
+            return;
+        }
+        final var contact = account.getRoster().getContact(from);
+        final Intent intent = new Intent(mXmppConnectionService, tel.xmpp.jabjab.ui.BlogPostActivity.class);
+        intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TOP);
+        intent.putExtra(tel.xmpp.jabjab.ui.BlogPostActivity.EXTRA_ITEM_ID, itemId);
+        intent.putExtra(tel.xmpp.jabjab.ui.BlogPostActivity.EXTRA_AUTHOR_JID, from.asBareJid().toString());
+        final PendingIntent pendingIntent =
+                PendingIntent.getActivity(
+                        mXmppConnectionService, itemId.hashCode(), intent,
+                        PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE);
+        final String contentText;
+        if (Strings.isNullOrEmpty(title)) {
+            contentText = mXmppConnectionService.getString(
+                    isEdit ? R.string.new_blog_post_updated_notification_text
+                            : R.string.new_blog_post_notification_text);
+        } else if (isEdit) {
+            contentText = mXmppConnectionService.getString(R.string.blog_post_updated_prefix, title);
+        } else {
+            contentText = title;
+        }
+        final Notification notification =
+                new Builder(mXmppConnectionService, NEW_BLOG_POSTS_CHANNEL)
+                        .setSmallIcon(R.drawable.ic_app_icon_notification)
+                        .setContentTitle(contact.getDisplayName())
+                        .setContentText(contentText)
+                        .setAutoCancel(true)
+                        .setContentIntent(pendingIntent)
+                        .build();
+        notify(NEW_BLOG_POST_NOTIFICATION_ID + Math.abs(itemId.hashCode() % 1000), notification);
+    }
+
     public synchronized void startRinging(
             final AbstractJingleConnection.Id id, final Set<Media> media) {
         showIncomingCallNotification(id, media, false);
diff --git a/src/main/java/tel/xmpp/jabjab/services/XmppConnectionService.java b/src/main/java/tel/xmpp/jabjab/services/XmppConnectionService.java
index 65bf51c..b4223f7 100644
--- a/src/main/java/tel/xmpp/jabjab/services/XmppConnectionService.java
+++ b/src/main/java/tel/xmpp/jabjab/services/XmppConnectionService.java
@@ -253,9 +253,15 @@ public class XmppConnectionService extends Service {
                                 connection.getManager(
                                         tel.xmpp.jabjab.xmpp.manager.StoriesManager.class)
                                         .fetchAndCache(contact.getAddress());
+                                // Blog has no live PEP push either (see BlogManager.fetchAndNotify
+                                // javadoc) — without this, new posts were only ever discovered when
+                                // the user manually opened BlogActivity, too late for a notification.
+                                connection.getManager(
+                                        tel.xmpp.jabjab.xmpp.manager.BlogManager.class)
+                                        .fetchAndNotify(contact.getAddress());
                             }
                         } catch (final Exception e) {
-                            Log.d(Config.LOGTAG, "story prefetch on presence failed: " + e.getMessage());
+                            Log.d(Config.LOGTAG, "story/blog prefetch on presence failed: " + e.getMessage());
                         }
                     }
                 }
@@ -280,6 +286,8 @@ public class XmppConnectionService extends Service {
             Collections.newSetFromMap(new WeakHashMap<OnAccountUpdate, Boolean>());
     private final Set<OnStoriesUpdate> mOnStoriesUpdates =
             Collections.newSetFromMap(new WeakHashMap<OnStoriesUpdate, Boolean>());
+    private final Set<OnBlogUpdate> mOnBlogUpdates =
+            Collections.newSetFromMap(new WeakHashMap<OnBlogUpdate, Boolean>());
     private final Set<OnCaptchaRequested> mOnCaptchaRequested =
             Collections.newSetFromMap(new WeakHashMap<OnCaptchaRequested, Boolean>());
     private final Set<OnRosterUpdate> mOnRosterUpdates =
@@ -3036,6 +3044,28 @@ public class XmppConnectionService extends Service {
         }
     }
 
+    public void setOnBlogUpdateListener(final OnBlogUpdate listener) {
+        final boolean remainingListeners;
+        synchronized (LISTENER_LOCK) {
+            remainingListeners = checkListeners();
+            this.mOnBlogUpdates.add(listener);
+        }
+        if (remainingListeners) {
+            switchToForeground();
+        }
+    }
+
+    public void removeOnBlogUpdateListener(final OnBlogUpdate listener) {
+        final boolean remainingListeners;
+        synchronized (LISTENER_LOCK) {
+            this.mOnBlogUpdates.remove(listener);
+            remainingListeners = checkListeners();
+        }
+        if (remainingListeners) {
+            switchToBackground();
+        }
+    }
+
     public void setOnCaptchaRequestedListener(OnCaptchaRequested listener) {
         final boolean remainingListeners;
         synchronized (LISTENER_LOCK) {
@@ -3208,7 +3238,8 @@ public class XmppConnectionService extends Service {
                 && this.mOnShowErrorToasts.isEmpty()
                 && this.onJingleRtpConnectionUpdate.isEmpty()
                 && this.mOnKeyStatusUpdated.isEmpty()
-                && this.mOnStoriesUpdates.isEmpty());
+                && this.mOnStoriesUpdates.isEmpty()
+                && this.mOnBlogUpdates.isEmpty());
     }
 
     private void switchToForeground() {
@@ -3878,6 +3909,12 @@ public class XmppConnectionService extends Service {
         }
     }
 
+    public void updateBlogUi() {
+        for (final OnBlogUpdate listener : threadSafeList(this.mOnBlogUpdates)) {
+            listener.onBlogUpdate();
+        }
+    }
+
     public void updateRosterUi() {
         for (OnRosterUpdate listener : threadSafeList(this.mOnRosterUpdates)) {
             listener.onRosterUpdate();
@@ -4547,6 +4584,10 @@ public class XmppConnectionService extends Service {
         void onStoriesUpdate();
     }
 
+    public interface OnBlogUpdate {
+        void onBlogUpdate();
+    }
+
     public interface OnCaptchaRequested {
         void onCaptchaRequested(
                 Account account,
diff --git a/src/main/java/tel/xmpp/jabjab/ui/BlogActivity.java b/src/main/java/tel/xmpp/jabjab/ui/BlogActivity.java
index ea66f09..aea6cee 100644
--- a/src/main/java/tel/xmpp/jabjab/ui/BlogActivity.java
+++ b/src/main/java/tel/xmpp/jabjab/ui/BlogActivity.java
@@ -48,7 +48,9 @@ import java.util.Map;
 import java.util.concurrent.atomic.AtomicInteger;
 
 public class BlogActivity extends XmppActivity
-        implements XmppConnectionService.OnAccountUpdate {
+        implements XmppConnectionService.OnAccountUpdate,
+                   XmppConnectionService.OnStoriesUpdate,
+                   XmppConnectionService.OnBlogUpdate {
 
     public static final String EXTRA_ACCOUNT    = "account";
     public static final String EXTRA_FILTER_JID = "filter_jid";
@@ -187,6 +189,20 @@ public class BlogActivity extends XmppActivity
         }
     }
 
+    // A story can arrive while the user is sitting on this screen — without this, the
+    // Stories tab's red dot would only refresh on the next onResume() (minimize/maximize).
+    @Override
+    public void onStoriesUpdate() {
+        BottomNavHelper.updateBadges(bottomNav, xmppConnectionService);
+    }
+
+    // New posts/edits from a live push or catch-up fetch bump badge_ts in the background —
+    // refresh this tab's own badge live too, not just the Stories tab's.
+    @Override
+    public void onBlogUpdate() {
+        BottomNavHelper.updateBadges(bottomNav, xmppConnectionService);
+    }
+
     private void updateDraftBanner() {
         final int count = draftStore.count();
         runOnUiThread(() -> {
diff --git a/src/main/java/tel/xmpp/jabjab/ui/BlogPostActivity.java b/src/main/java/tel/xmpp/jabjab/ui/BlogPostActivity.java
index 5ef681e..eedbbbb 100644
--- a/src/main/java/tel/xmpp/jabjab/ui/BlogPostActivity.java
+++ b/src/main/java/tel/xmpp/jabjab/ui/BlogPostActivity.java
@@ -20,6 +20,7 @@ import com.google.android.material.appbar.MaterialToolbar;
 import com.google.common.util.concurrent.FutureCallback;
 import com.google.common.util.concurrent.Futures;
 import com.google.common.util.concurrent.MoreExecutors;
+import tel.xmpp.jabjab.Config;
 import tel.xmpp.jabjab.R;
 import tel.xmpp.jabjab.ui.util.Markdown;
 import tel.xmpp.jabjab.xmpp.Jid;
@@ -98,9 +99,21 @@ public class BlogPostActivity extends XmppActivity {
                 metaView.setVisibility(View.GONE);
                 bodyView.setVisibility(View.GONE);
             }
-        } else {
+        } else if (intent.hasExtra(EXTRA_TITLE) || intent.hasExtra(EXTRA_META)) {
+            // Normal in-app open (tapped from BlogActivity's list) — content already
+            // provided in the extras, nothing to fetch.
             loadingView.setVisibility(View.GONE);
             populateView(intent.getStringExtra(EXTRA_META));
+        } else {
+            // Opened via a "new blog post" notification: only itemId + authorJid are known,
+            // no content yet. Previously this fell into the branch above and called
+            // populateView(null) immediately — rendering a permanently blank page instead of
+            // waiting for the async fetch below. Show the spinner; onBackendConnected() ->
+            // fetchByItemId() fetches and calls populateView() once the real content arrives.
+            loadingView.setVisibility(View.VISIBLE);
+            titleView.setVisibility(View.GONE);
+            metaView.setVisibility(View.GONE);
+            bodyView.setVisibility(View.GONE);
         }
     }
 
@@ -194,9 +207,17 @@ public class BlogPostActivity extends XmppActivity {
 
     private void fetchByItemId() {
         if (xmppConnectionService == null || itemId == null) return;
+        // Case 1: authorJid is one of our own local accounts (edit flow, or viewing your own
+        // post) — fetch via that account's own connection, self-jid implied by fetchItem().
+        // Must require isOnlineAndConnected(), not just conn != null: a *disabled* own account
+        // still has a non-null connection object and still matches
+        // authorJid when it's the post's author, but its connection is dead — fetchItem() over
+        // it never times out or calls back at all, so the spinner previously hung forever with
+        // no error. A disabled account's posts are still reachable via Case 2 below, over any
+        // other online account's connection, same as a genuine contact's post.
         for (final var acc : xmppConnectionService.getAccounts()) {
             final var conn = acc.getXmppConnection();
-            if (conn == null) continue;
+            if (conn == null || !acc.isOnlineAndConnected()) continue;
             if (authorJid != null && !authorJid.equals(acc.getJid().asBareJid().toString())) continue;
             final var mgr = conn.getManager(BlogManager.class);
             loadingView.setVisibility(View.VISIBLE);
@@ -208,7 +229,16 @@ public class BlogPostActivity extends XmppActivity {
                     new FutureCallback<Entry>() {
                         @Override
                             public void onSuccess(final Entry entry) {
-                                if (entry == null || !entry.isValid()) return;
+                                if (entry == null || !entry.isValid()) {
+                                    Log.d(Config.LOGTAG, "BLOG post view: fetchItem(" + itemId
+                                            + ") returned " + (entry == null ? "null" : "invalid") + " entry");
+                                    runOnUiThread(() -> {
+                                        loadingView.setVisibility(View.GONE);
+                                        metaView.setText("Could not load post");
+                                        metaView.setVisibility(View.VISIBLE);
+                                    });
+                                    return;
+                                }
                                 pendingTitle = entry.getTitle();
                                 pendingBody = entry.getSummary();
                                 pendingHeaderUrl = entry.getHeaderImage();
@@ -230,14 +260,92 @@ public class BlogPostActivity extends XmppActivity {
 
                         @Override
                         public void onFailure(final Throwable t) {
+                            Log.d(Config.LOGTAG, "BLOG post view: fetchItem(" + itemId
+                                    + ") failed: " + t.getMessage(), t);
                             runOnUiThread(() -> {
                                 loadingView.setVisibility(View.GONE);
+                                metaView.setText("Could not load post");
+                                metaView.setVisibility(View.VISIBLE);
                             });
                         }
                     },
                     MoreExecutors.directExecutor());
             return;
         }
+        // Case 2: authorJid belongs to someone else (a contact's post, e.g. opened via a
+        // "new blog post" notification) — no local account's own jid matches, so the loop
+        // above never fires fetchItem() and the spinner previously spun forever. Fetch the
+        // item from the author's own PubSub node instead, via any online connection.
+        if (authorJid != null) {
+            Log.d(Config.LOGTAG, "BLOG post view: fetching " + itemId + " from " + authorJid);
+            for (final var acc : xmppConnectionService.getAccounts()) {
+                final var conn = acc.getXmppConnection();
+                if (conn == null || !acc.isOnlineAndConnected()) continue;
+                final Jid authorJidObj;
+                try {
+                    authorJidObj = Jid.of(authorJid).asBareJid();
+                } catch (final Exception e) {
+                    break;
+                }
+                final var mgr = conn.getManager(BlogManager.class);
+                loadingView.setVisibility(View.VISIBLE);
+                titleView.setVisibility(View.GONE);
+                metaView.setVisibility(View.GONE);
+                bodyView.setVisibility(View.GONE);
+                final String localpart = authorJid.contains("@")
+                        ? authorJid.substring(0, authorJid.indexOf('@')) : authorJid;
+                Futures.addCallback(
+                        mgr.fetchItemFrom(authorJidObj, itemId),
+                        new FutureCallback<Entry>() {
+                            @Override
+                            public void onSuccess(final Entry entry) {
+                                if (entry == null || !entry.isValid()) {
+                                    Log.d(Config.LOGTAG, "BLOG post view: fetchItemFrom("
+                                            + authorJidObj + ", " + itemId + ") returned "
+                                            + (entry == null ? "null" : "invalid") + " entry");
+                                    runOnUiThread(() -> {
+                                        loadingView.setVisibility(View.GONE);
+                                        metaView.setText("Could not load post");
+                                        metaView.setVisibility(View.VISIBLE);
+                                    });
+                                    return;
+                                }
+                                pendingTitle = entry.getTitle();
+                                pendingBody = entry.getSummary();
+                                pendingHeaderUrl = entry.getHeaderImage();
+                                pendingPublished = entry.getPublished();
+                                final String published = pendingPublished;
+                                final StringBuilder meta = new StringBuilder();
+                                meta.append("Written by <b>@").append(localpart).append("</b>");
+                                if (published != null) {
+                                    meta.append("\npublished on <b>")
+                                            .append(formatDate(published)).append("</b>");
+                                    final String updDate = entry.getUpdated();
+                                    if (updDate != null && !updDate.equals(published)) {
+                                        meta.append(" and last edited <b>")
+                                                .append(formatDate(updDate)).append("</b>");
+                                    }
+                                }
+                                final String metaStr = meta.toString();
+                                runOnUiThread(() -> populateView(metaStr));
+                            }
+
+                            @Override
+                            public void onFailure(final Throwable t) {
+                                Log.d(Config.LOGTAG, "BLOG post view: fetchItemFrom(" + authorJidObj
+                                        + ", " + itemId + ") failed: " + t.getMessage(), t);
+                                runOnUiThread(() -> {
+                                    loadingView.setVisibility(View.GONE);
+                                    metaView.setText("Could not load post");
+                                    metaView.setVisibility(View.VISIBLE);
+                                });
+                            }
+                        },
+                        MoreExecutors.directExecutor());
+                return;
+            }
+        }
+        loadingView.setVisibility(View.GONE);
     }
 
     private void fetchAndDisplay(final String user, final String postId) {
diff --git a/src/main/java/tel/xmpp/jabjab/ui/ConversationsActivity.java b/src/main/java/tel/xmpp/jabjab/ui/ConversationsActivity.java
index be5be1e..6d78951 100644
--- a/src/main/java/tel/xmpp/jabjab/ui/ConversationsActivity.java
+++ b/src/main/java/tel/xmpp/jabjab/ui/ConversationsActivity.java
@@ -85,6 +85,8 @@ public class ConversationsActivity extends QrCodeProcessingActivity
                 XmppConnectionService.OnAccountUpdate,
                 XmppConnectionService.OnConversationUpdate,
                 XmppConnectionService.OnRosterUpdate,
+                XmppConnectionService.OnStoriesUpdate,
+                XmppConnectionService.OnBlogUpdate,
                 OnUpdateBlocklist,
                 XmppConnectionService.OnShowErrorToast {
 
@@ -140,6 +142,20 @@ public class ConversationsActivity extends QrCodeProcessingActivity
         BottomNavHelper.updateBadges(this.binding.bottomNav, xmppConnectionService);
     }
 
+    // Stories/blog pushes and catch-up fetches update badge_ts in the background regardless
+    // of which screen is in front — without these, the bottom-nav red dot only refreshed on
+    // onResume() (i.e. minimizing and maximizing the app), since nothing else here was ever
+    // told a new story/blog post had arrived while this screen stayed in the foreground.
+    @Override
+    public void onStoriesUpdate() {
+        BottomNavHelper.updateBadges(this.binding.bottomNav, xmppConnectionService);
+    }
+
+    @Override
+    public void onBlogUpdate() {
+        BottomNavHelper.updateBadges(this.binding.bottomNav, xmppConnectionService);
+    }
+
     @Override
     protected void onBackendConnected() {
         xmppConnectionService.getNotificationService().setIsInForeground(true);
diff --git a/src/main/java/tel/xmpp/jabjab/ui/StoriesActivity.java b/src/main/java/tel/xmpp/jabjab/ui/StoriesActivity.java
index a8f5127..819121b 100644
--- a/src/main/java/tel/xmpp/jabjab/ui/StoriesActivity.java
+++ b/src/main/java/tel/xmpp/jabjab/ui/StoriesActivity.java
@@ -53,7 +53,8 @@ import java.util.concurrent.Executors;
 
 public class StoriesActivity extends XmppActivity
         implements tel.xmpp.jabjab.services.XmppConnectionService.OnAccountUpdate,
-                   tel.xmpp.jabjab.services.XmppConnectionService.OnStoriesUpdate {
+                   tel.xmpp.jabjab.services.XmppConnectionService.OnStoriesUpdate,
+                   tel.xmpp.jabjab.services.XmppConnectionService.OnBlogUpdate {
 
     public static final String EXTRA_ACCOUNT = "account";
 
@@ -208,6 +209,13 @@ public class StoriesActivity extends XmppActivity
         BottomNavHelper.updateBadges(bottomNav, xmppConnectionService);
     }
 
+    // A blog post can arrive while the user is sitting on this screen — without this, the
+    // Blog tab's red dot would only refresh on the next onResume() (minimize/maximize).
+    @Override
+    public void onBlogUpdate() {
+        BottomNavHelper.updateBadges(bottomNav, xmppConnectionService);
+    }
+
     @Override
     protected void onSaveInstanceState(final android.os.Bundle outState) {
         super.onSaveInstanceState(outState);
@@ -323,8 +331,14 @@ public class StoriesActivity extends XmppActivity
         final DatabaseBackend db = DatabaseBackend.getInstance(this);
         final List<StoryEntry> newOwn = new ArrayList<>();
         final List<StoryEntry> newContacts = new ArrayList<>();
+        // Only ENABLED own accounts are excluded from the contacts list below — a disabled
+        // own account that's still in an enabled account's roster
+        // should be shown like any other contact's story there, not hidden entirely, since
+        // it no longer gets its own "your story" tile (see the isEnabled() check just below).
         final java.util.Set<String> ownJids = new java.util.HashSet<>();
-        for (final Account a : allOwnAccounts) ownJids.add(a.getJid().asBareJid().toString());
+        for (final Account a : allOwnAccounts) {
+            if (a.isEnabled()) ownJids.add(a.getJid().asBareJid().toString());
+        }
         // Deduplicate contacts across accounts: a friend may be cached under multiple account_jids.
         final java.util.Set<String> seenContactJids = new java.util.HashSet<>();
 
@@ -332,8 +346,11 @@ public class StoriesActivity extends XmppActivity
             final String accountJid = a.getJid().asBareJid().toString();
             final Map<String, LinkedHashMap<String, Story>> cache = db.getStoryCacheForAccount(accountJid);
 
-            // Own stories: contact_jid == account_jid
-            final LinkedHashMap<String, Story> ownStories = cache.get(accountJid);
+            // Own stories: contact_jid == account_jid. Skip disabled accounts here — a
+            // disabled account shouldn't get its own visible "your story" tile (previously
+            // showed up as a second, identical-looking own-avatar tile); instead its stories
+            // surface as a normal contact entry below via any enabled account's roster.
+            final LinkedHashMap<String, Story> ownStories = a.isEnabled() ? cache.get(accountJid) : null;
             if (ownStories != null && !ownStories.isEmpty()) {
                 final List<String> ownerJids = new ArrayList<>();
                 for (int i = 0; i < ownStories.size(); i++) ownerJids.add(accountJid);
@@ -353,6 +370,12 @@ public class StoriesActivity extends XmppActivity
             }
             for (final Map.Entry<String, LinkedHashMap<String, Story>> e : cache.entrySet()) {
                 final String contactJid = e.getKey();
+                // Never render an account's own cache self-entry (contact_jid == account_jid)
+                // as a "contact" story from its own perspective — even when disabled, that
+                // entry is handled by the ownStories block above (or intentionally hidden
+                // there). A disabled own account should only ever surface as a contact story
+                // via *another*, enabled account's roster relationship (checked below).
+                if (contactJid.equals(accountJid)) continue;
                 if (ownJids.contains(contactJid)) continue; // own story handled above
                 if (!seenContactJids.add(contactJid)) continue; // already rendered from another account
                 final LinkedHashMap<String, Story> stories = e.getValue();
diff --git a/src/main/java/tel/xmpp/jabjab/ui/XmppActivity.java b/src/main/java/tel/xmpp/jabjab/ui/XmppActivity.java
index afa6ed0..2d6fcbc 100644
--- a/src/main/java/tel/xmpp/jabjab/ui/XmppActivity.java
+++ b/src/main/java/tel/xmpp/jabjab/ui/XmppActivity.java
@@ -520,6 +520,14 @@ public abstract class XmppActivity extends ActionBarActivity {
             this.xmppConnectionService.setOnRtpConnectionUpdateListener(
                     (XmppConnectionService.OnJingleRtpConnectionUpdate) this);
         }
+        if (this instanceof XmppConnectionService.OnStoriesUpdate) {
+            this.xmppConnectionService.setOnStoriesUpdateListener(
+                    (XmppConnectionService.OnStoriesUpdate) this);
+        }
+        if (this instanceof XmppConnectionService.OnBlogUpdate) {
+            this.xmppConnectionService.setOnBlogUpdateListener(
+                    (XmppConnectionService.OnBlogUpdate) this);
+        }
     }
 
     protected void unregisterListeners() {
@@ -557,6 +565,14 @@ public abstract class XmppActivity extends ActionBarActivity {
             this.xmppConnectionService.removeRtpConnectionUpdateListener(
                     (XmppConnectionService.OnJingleRtpConnectionUpdate) this);
         }
+        if (this instanceof XmppConnectionService.OnStoriesUpdate) {
+            this.xmppConnectionService.removeOnStoriesUpdateListener(
+                    (XmppConnectionService.OnStoriesUpdate) this);
+        }
+        if (this instanceof XmppConnectionService.OnBlogUpdate) {
+            this.xmppConnectionService.removeOnBlogUpdateListener(
+                    (XmppConnectionService.OnBlogUpdate) this);
+        }
     }
 
     @Override
diff --git a/src/main/java/tel/xmpp/jabjab/ui/util/BlogNotifiedTracker.java b/src/main/java/tel/xmpp/jabjab/ui/util/BlogNotifiedTracker.java
new file mode 100644
index 0000000..1d7f1d2
--- /dev/null
+++ b/src/main/java/tel/xmpp/jabjab/ui/util/BlogNotifiedTracker.java
@@ -0,0 +1,59 @@
+package tel.xmpp.jabjab.ui.util;
+
+import android.content.Context;
+import android.content.SharedPreferences;
+
+/**
+ * Tracks, per blog post item id, when we last produced a "new blog post" notification for
+ * it. Blog has no SQLite cache to attach this to (posts are fetched on demand — see
+ * {@code BlogManager}), so this mirrors the existing lightweight SharedPreferences-backed
+ * stores already used for blog feature state ({@code jabjab_blog_drafts},
+ * {@code jabjab_blog_headers}).
+ *
+ * A post edit republishes with the SAME item id (see {@code BlogManager.editPost}), so the
+ * same PEP push we use to detect new posts is also how we learn about edits. Rather than a
+ * plain "already seen" set (which would silently swallow every edit forever), this stores a
+ * timestamp and re-notifies after {@link #RENOTIFY_THROTTLE_MS} — so an edit made well after
+ * the fact still surfaces, while rapid-fire re-edits (fixing a typo three times) don't spam.
+ */
+public class BlogNotifiedTracker {
+
+    private static final String PREFS_NAME = "jabjab_blog_notified";
+    private static final String KEY_PREFIX = "notified_at_";
+    private static final long RENOTIFY_THROTTLE_MS = 15L * 60L * 1000L; // 15 minutes
+
+    private static BlogNotifiedTracker instance;
+    private final SharedPreferences prefs;
+
+    private BlogNotifiedTracker(final Context context) {
+        this.prefs =
+                context.getApplicationContext()
+                        .getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE);
+    }
+
+    public static synchronized BlogNotifiedTracker getInstance(final Context context) {
+        if (instance == null) {
+            instance = new BlogNotifiedTracker(context);
+        }
+        return instance;
+    }
+
+    /** True the first time we see itemId, and again once RENOTIFY_THROTTLE_MS has passed
+     * since the last notification for it — covers both a genuinely new post and an edit
+     * that arrives long enough after the last notification to be worth surfacing again. */
+    public boolean shouldNotify(final String itemId) {
+        final long lastNotifiedAt = prefs.getLong(KEY_PREFIX + itemId, 0L);
+        return lastNotifiedAt == 0L
+                || System.currentTimeMillis() - lastNotifiedAt >= RENOTIFY_THROTTLE_MS;
+    }
+
+    /** True if we've notified about this itemId before — distinguishes "new post" from
+     * "edited post" wording when {@link #shouldNotify} allows a re-notification through. */
+    public boolean wasAlreadyNotifiedBefore(final String itemId) {
+        return prefs.getLong(KEY_PREFIX + itemId, 0L) != 0L;
+    }
+
+    public void markNotified(final String itemId) {
+        prefs.edit().putLong(KEY_PREFIX + itemId, System.currentTimeMillis()).apply();
+    }
+}
diff --git a/src/main/java/tel/xmpp/jabjab/ui/util/UpdateChecker.java b/src/main/java/tel/xmpp/jabjab/ui/util/UpdateChecker.java
index 79359bd..15aa8b7 100644
--- a/src/main/java/tel/xmpp/jabjab/ui/util/UpdateChecker.java
+++ b/src/main/java/tel/xmpp/jabjab/ui/util/UpdateChecker.java
@@ -203,6 +203,12 @@ public class UpdateChecker {
             return;
         }
         sCancelled = false;
+        // Show the progress notification immediately, before any network I/O: the
+        // "update available" notification has setAutoCancel(true), so it's dismissed
+        // the instant it's tapped. Without this, there's a visible gap — no
+        // notification at all — until the first HTTP response arrives, which can take
+        // a few seconds and reads as "it just disappeared" rather than "download started."
+        showDownloadProgress(versionCode, versionName, 0);
         Executors.newSingleThreadExecutor().execute(() -> {
             try {
                 downloadAndInstallInternal(downloadUrl, versionCode, versionName, fileSize, sha256);
diff --git a/src/main/java/tel/xmpp/jabjab/xmpp/manager/BlogManager.java b/src/main/java/tel/xmpp/jabjab/xmpp/manager/BlogManager.java
index 7efd001..7e8d673 100644
--- a/src/main/java/tel/xmpp/jabjab/xmpp/manager/BlogManager.java
+++ b/src/main/java/tel/xmpp/jabjab/xmpp/manager/BlogManager.java
@@ -43,6 +43,55 @@ public class BlogManager extends AbstractManager {
         Log.d(Config.LOGTAG, "BLOG push from " + from + ": " + entryMap.size() + " items");
         if (!entryMap.isEmpty()) {
             bumpBadgeIfNew(entryMap);
+            service.updateBlogUi();
+            notifyNewPosts(from, entryMap);
+        }
+    }
+
+    /** Only notify for items not already notified (per-item id, tracked in
+     * {@link tel.xmpp.jabjab.ui.util.BlogNotifiedTracker} since blog has no DB cache
+     * to diff against), and only for actual roster contacts, never the account's own
+     * posts (a self-authored blog is never delivered back via a contact's PEP push,
+     * but guard explicitly since `from` is attacker/server-controlled input either way). */
+    private void notifyNewPosts(final Jid from, final Map<String, Entry> entryMap) {
+        if (from.asBareJid().equals(getAccount().getJid().asBareJid())) {
+            Log.d(Config.LOGTAG, "BLOG notify: skipping, from == self (" + from + ")");
+            return;
+        }
+        // Same reasoning as StoriesManager.notifyNewStories: skip only if `from` is one of the
+        // user's own *enabled* other xmpp.tel accounts — genuinely you. A disabled own account
+        // (e.g. a debug account) is still fetched and shown in BlogActivity as normal content,
+        // so it's fine to notify about those.
+        for (final var ownAccount : tel.xmpp.jabjab.ui.util.XmppTelAccounts.get(service)) {
+            if (ownAccount.isEnabled() && ownAccount.getJid().asBareJid().equals(from.asBareJid())) {
+                Log.d(Config.LOGTAG, "BLOG notify: skipping, from == own enabled account (" + from + ")");
+                return;
+            }
+        }
+        final var contact = getAccount().getRoster().getContact(from);
+        if (contact == null || !contact.showInRoster()) {
+            Log.d(Config.LOGTAG, "BLOG notify: skipping " + from
+                    + " — contact=" + contact
+                    + " showInRoster=" + (contact != null && contact.showInRoster()));
+            return;
+        }
+        final var tracker =
+                tel.xmpp.jabjab.ui.util.BlogNotifiedTracker.getInstance(
+                        service.getApplicationContext());
+        for (final Map.Entry<String, Entry> e : entryMap.entrySet()) {
+            final String itemId = e.getKey();
+            if (!tracker.shouldNotify(itemId)) {
+                Log.d(Config.LOGTAG, "BLOG notify: skipping " + itemId
+                        + " — notified within the last hour");
+                continue;
+            }
+            final boolean isEdit = tracker.wasAlreadyNotifiedBefore(itemId);
+            tracker.markNotified(itemId);
+            Log.d(Config.LOGTAG, "BLOG notify: pushing " + (isEdit ? "edit" : "new post")
+                    + " notification for " + itemId + " from " + from
+                    + " title=" + e.getValue().getTitle());
+            service.getNotificationService()
+                    .pushNewBlogPost(getAccount(), from, itemId, e.getValue().getTitle(), isEdit);
         }
     }
 
@@ -194,4 +243,44 @@ public class BlogManager extends AbstractManager {
         return getManager(PubSubManager.class)
                 .fetchItem(targetJid, BLOG_NODE, itemId, Entry.class);
     }
+
+    /**
+     * Fetches bareJid's blog posts and notifies for any not already seen by
+     * {@link tel.xmpp.jabjab.ui.util.BlogNotifiedTracker}. Called in the background by
+     * XmppConnectionService's onContactStatusChanged (contact comes online) — mirrors
+     * StoriesManager.fetchAndCache(), since blog has no live PEP push either (neither
+     * node advertises a +notify disco feature, so BlogActivity's on-open fetch is the
+     * only path new posts were otherwise discovered through, by which point the user
+     * has already seen them).
+     */
+    public void fetchAndNotify(final Jid bareJid) {
+        if (bareJid.asBareJid().equals(getAccount().getJid().asBareJid())) {
+            return;
+        }
+        Log.d(Config.LOGTAG, "BLOG fetchAndNotify: " + bareJid);
+        Futures.addCallback(
+                getManager(PubSubManager.class).fetchItems(bareJid, BLOG_NODE, Entry.class),
+                new com.google.common.util.concurrent.FutureCallback<Map<String, Entry>>() {
+                    @Override
+                    public void onSuccess(final Map<String, Entry> items) {
+                        Log.d(Config.LOGTAG, "BLOG fetchAndNotify success: "
+                                + bareJid + " → " + items.size() + " items");
+                        if (!items.isEmpty()) {
+                            bumpBadgeIfNew(items);
+                            service.updateBlogUi();
+                            notifyNewPosts(bareJid, items);
+                        }
+                    }
+
+                    @Override
+                    public void onFailure(final Throwable t) {
+                        final String msg = t.getMessage();
+                        if (msg == null || !msg.contains("item-not-found")) {
+                            Log.d(Config.LOGTAG, "BLOG fetchAndNotify failed for "
+                                    + bareJid + ": " + msg);
+                        }
+                    }
+                },
+                MoreExecutors.directExecutor());
+    }
 }
diff --git a/src/main/java/tel/xmpp/jabjab/xmpp/manager/DiscoManager.java b/src/main/java/tel/xmpp/jabjab/xmpp/manager/DiscoManager.java
index aaed609..3f885b8 100644
--- a/src/main/java/tel/xmpp/jabjab/xmpp/manager/DiscoManager.java
+++ b/src/main/java/tel/xmpp/jabjab/xmpp/manager/DiscoManager.java
@@ -346,6 +346,12 @@ public class DiscoManager extends AbstractManager {
             features.addAll(MESSAGE_CORRECTION_FEATURES);
         }
         features.add(AxolotlService.PEP_DEVICE_LIST_NOTIFY);
+        // Without advertising +notify for these two PEP nodes, Prosody (per XEP-0163) never
+        // auto-pushes new items to us at all — stories/blog notifications were only ever
+        // firing via catch-up polling on presence/reconnect events, not in real time like
+        // chat messages. This makes the server actually deliver live push for both.
+        features.add(notify(StoriesManager.STORIES_NODE));
+        features.add(notify(BlogManager.BLOG_NODE));
         if (!appSettings.isUseTor() && !account.isOnion()) {
             features.addAll(PRIVACY_SENSITIVE);
             features.addAll(VOIP_NAMESPACES);
diff --git a/src/main/java/tel/xmpp/jabjab/xmpp/manager/StoriesManager.java b/src/main/java/tel/xmpp/jabjab/xmpp/manager/StoriesManager.java
index c787a51..d2081b9 100644
--- a/src/main/java/tel/xmpp/jabjab/xmpp/manager/StoriesManager.java
+++ b/src/main/java/tel/xmpp/jabjab/xmpp/manager/StoriesManager.java
@@ -76,10 +76,43 @@ public class StoriesManager extends AbstractManager {
                     db.getStoryCacheForAccount(accountJid);
             final LinkedHashMap<String, Story> merged =
                     existing.getOrDefault(contactJid, new LinkedHashMap<>());
+            // Snapshot the previously-known item ids BEFORE putAll mutates `merged` below —
+            // keySet() is a live view, so this must be copied first to know which of the
+            // incoming `stories` are genuinely new (for the notification below), not just
+            // a re-push of something we already have cached.
+            final java.util.Set<String> previouslyKnownIds =
+                    new java.util.HashSet<>(merged.keySet());
             merged.putAll(stories);
             db.replaceStoryCacheForContact(accountJid, contactJid, merged);
             bumpBadgeIfNew(stories);
             service.updateStoriesUi();
+            notifyNewStories(from, stories, previouslyKnownIds);
+        }
+    }
+
+    /** Only notify for items we hadn't already cached, and only for actual roster
+     * contacts — a bare-JID PEP sender isn't necessarily someone the user follows. */
+    private void notifyNewStories(
+            final Jid from, final Map<String, Story> stories,
+            final java.util.Set<String> previouslyKnownIds) {
+        // Skip only if `from` is one of the user's own *enabled* other xmpp.tel accounts on
+        // this device — that's genuinely you, and it never gets a "contact" tile (see
+        // loadFromCache's ownJids). A *disabled* own account (e.g. a debug account) still
+        // surfaces as a normal contact story via an enabled account's roster relationship,
+        // so it's fine — expected, even — to notify about those.
+        for (final var ownAccount : tel.xmpp.jabjab.ui.util.XmppTelAccounts.get(service)) {
+            if (ownAccount.isEnabled() && ownAccount.getJid().asBareJid().equals(from.asBareJid())) {
+                return;
+            }
+        }
+        final var contact = getAccount().getRoster().getContact(from);
+        if (contact == null || !contact.showInRoster()) {
+            return;
+        }
+        for (final Map.Entry<String, Story> e : stories.entrySet()) {
+            if (previouslyKnownIds.contains(e.getKey())) continue;
+            service.getNotificationService()
+                    .pushNewStory(getAccount(), from, e.getKey(), e.getValue());
         }
     }
 
@@ -251,10 +284,21 @@ public class StoriesManager extends AbstractManager {
                         Log.d(Config.LOGTAG, "STORIES fetchAndCache success: "
                                 + contactJid + " → " + stories.size() + " stories");
                         final DatabaseBackend db = DatabaseBackend.getInstance(context);
+                        // Snapshot before the full replace below — this catch-up path (account
+                        // just came online, or a contact's presence just appeared) replaces the
+                        // whole cache for this contact, unlike handleIncomingItems' merge, so
+                        // "new" has to be computed against what was cached a moment ago.
+                        final java.util.Set<String> previouslyKnownIds =
+                                db.getCachedStoryItemIds(accountJid, contactJid);
                         db.replaceStoryCacheForContact(accountJid, contactJid, stories);
                         // Bump badge only if a story is newer than the user's last read time
                         bumpBadgeIfNew(stories);
                         service.updateStoriesUi();
+                        // Never notify about the account's own stories (fetchAndCacheSelf()
+                        // calls this same method with the account's own bare JID).
+                        if (!bareJid.asBareJid().equals(getAccount().getJid().asBareJid())) {
+                            notifyNewStories(bareJid, stories, previouslyKnownIds);
+                        }
                     }
                     @Override
                     public void onFailure(final Throwable t) {
diff --git a/src/main/res/values-ar/strings.xml b/src/main/res/values-ar/strings.xml
index 6e9b6f6..10619b3 100644
--- a/src/main/res/values-ar/strings.xml
+++ b/src/main/res/values-ar/strings.xml
@@ -736,6 +736,16 @@
     <string name="translation_do_not_ask_again">السماح دائمًا باستخدام ترجمة جوجل كبديل</string>
     <string name="translate_choose_language">ترجمة إلى…</string>
     <string name="translate_more_languages">المزيد…</string>
+    <string name="pref_notify_new_stories">قصص جديدة</string>
+    <string name="pref_notify_new_stories_summary">التنبيه عند نشر جهة اتصال قصة جديدة.</string>
+    <string name="pref_notify_new_blog_posts">منشورات مدونة جديدة</string>
+    <string name="pref_notify_new_blog_posts_summary">التنبيه عند نشر جهة اتصال منشور مدونة جديد.</string>
+    <string name="new_stories_channel_name">قصص جديدة</string>
+    <string name="new_blog_posts_channel_name">منشورات مدونة جديدة</string>
+    <string name="new_story_notification_text">نشر قصة جديدة</string>
+    <string name="new_blog_post_notification_text">نشر منشور مدونة جديد</string>
+    <string name="new_blog_post_updated_notification_text">قام بتحديث منشور المدونة</string>
+    <string name="blog_post_updated_prefix">تم التحديث: %1$s</string>
     <string name="ephemeral_upload_fallback_title">إرسال بدون حذف تلقائي؟</string>
     <string name="ephemeral_upload_fallback_explained">خادم جهة الاتصال هذه لا يدعم المرفقات في الرسائل المؤقتة. سيتم رفع الملف بشكل عادي ولن يُحذف تلقائيًا.</string>
     <string name="ephemeral_upload_fallback_allow">إرسال على أي حال</string>
diff --git a/src/main/res/values-cs/strings.xml b/src/main/res/values-cs/strings.xml
index 5041fac..4f95370 100644
--- a/src/main/res/values-cs/strings.xml
+++ b/src/main/res/values-cs/strings.xml
@@ -1437,6 +1437,16 @@
     <string name="translation_do_not_ask_again">Vždy povolit záložní Překladač Google</string>
     <string name="translate_choose_language">Přeložit do…</string>
     <string name="translate_more_languages">Více…</string>
+    <string name="pref_notify_new_stories">Nové příběhy</string>
+    <string name="pref_notify_new_stories_summary">Upozornit, když kontakt zveřejní nový příběh.</string>
+    <string name="pref_notify_new_blog_posts">Nové příspěvky na blogu</string>
+    <string name="pref_notify_new_blog_posts_summary">Upozornit, když kontakt zveřejní nový příspěvek na blogu.</string>
+    <string name="new_stories_channel_name">Nové příběhy</string>
+    <string name="new_blog_posts_channel_name">Nové příspěvky na blogu</string>
+    <string name="new_story_notification_text">zveřejnil(a) nový příběh</string>
+    <string name="new_blog_post_notification_text">zveřejnil(a) nový příspěvek na blogu</string>
+    <string name="new_blog_post_updated_notification_text">aktualizoval(a) příspěvek na blogu</string>
+    <string name="blog_post_updated_prefix">Aktualizováno: %1$s</string>
     <string name="ephemeral_upload_fallback_title">Odeslat bez automatického mazání?</string>
     <string name="ephemeral_upload_fallback_explained">Server tohoto kontaktu nepodporuje přílohy v mizejících zprávách. Soubor bude nahrán běžným způsobem a nebude automaticky smazán.</string>
     <string name="ephemeral_upload_fallback_allow">Přesto odeslat</string>
diff --git a/src/main/res/values-da-rDK/strings.xml b/src/main/res/values-da-rDK/strings.xml
index 9fb8abf..979bad9 100644
--- a/src/main/res/values-da-rDK/strings.xml
+++ b/src/main/res/values-da-rDK/strings.xml
@@ -1406,6 +1406,16 @@
     <string name="translation_do_not_ask_again">Tillad altid tilbagefald til Google Oversæt</string>
     <string name="translate_choose_language">Oversæt til…</string>
     <string name="translate_more_languages">Mere…</string>
+    <string name="pref_notify_new_stories">Nye stories</string>
+    <string name="pref_notify_new_stories_summary">Giv besked, når en kontakt udgiver en ny story.</string>
+    <string name="pref_notify_new_blog_posts">Nye blogindlæg</string>
+    <string name="pref_notify_new_blog_posts_summary">Giv besked, når en kontakt udgiver et nyt blogindlæg.</string>
+    <string name="new_stories_channel_name">Nye stories</string>
+    <string name="new_blog_posts_channel_name">Nye blogindlæg</string>
+    <string name="new_story_notification_text">udgav en ny story</string>
+    <string name="new_blog_post_notification_text">udgav et nyt blogindlæg</string>
+    <string name="new_blog_post_updated_notification_text">opdaterede sit blogindlæg</string>
+    <string name="blog_post_updated_prefix">Opdateret: %1$s</string>
     <string name="ephemeral_upload_fallback_title">Send uden automatisk sletning?</string>
     <string name="ephemeral_upload_fallback_explained">Denne kontakts server understøtter ikke vedhæftede filer i selvdestruerende beskeder. Filen uploades normalt og slettes ikke automatisk.</string>
     <string name="ephemeral_upload_fallback_allow">Send alligevel</string>
diff --git a/src/main/res/values-de/strings.xml b/src/main/res/values-de/strings.xml
index bf9c801..5cd61aa 100644
--- a/src/main/res/values-de/strings.xml
+++ b/src/main/res/values-de/strings.xml
@@ -1310,6 +1310,16 @@
     <string name="translation_do_not_ask_again">Rückgriff auf Google Übersetzer immer erlauben</string>
     <string name="translate_choose_language">Übersetzen nach…</string>
     <string name="translate_more_languages">Mehr…</string>
+    <string name="pref_notify_new_stories">Neue Storys</string>
+    <string name="pref_notify_new_stories_summary">Benachrichtigen, wenn ein Kontakt eine neue Story veröffentlicht.</string>
+    <string name="pref_notify_new_blog_posts">Neue Blogbeiträge</string>
+    <string name="pref_notify_new_blog_posts_summary">Benachrichtigen, wenn ein Kontakt einen neuen Blogbeitrag veröffentlicht.</string>
+    <string name="new_stories_channel_name">Neue Storys</string>
+    <string name="new_blog_posts_channel_name">Neue Blogbeiträge</string>
+    <string name="new_story_notification_text">hat eine neue Story veröffentlicht</string>
+    <string name="new_blog_post_notification_text">hat einen neuen Blogbeitrag veröffentlicht</string>
+    <string name="new_blog_post_updated_notification_text">hat den Blogbeitrag aktualisiert</string>
+    <string name="blog_post_updated_prefix">Aktualisiert: %1$s</string>
     <string name="ephemeral_upload_fallback_title">Ohne automatisches Löschen senden?</string>
     <string name="ephemeral_upload_fallback_explained">Der Server dieses Kontakts unterstützt keine Anhänge in selbstlöschenden Nachrichten. Die Datei wird normal hochgeladen und wird nicht automatisch gelöscht.</string>
     <string name="ephemeral_upload_fallback_allow">Trotzdem senden</string>
diff --git a/src/main/res/values-es/strings.xml b/src/main/res/values-es/strings.xml
index f20e833..7dc51e1 100644
--- a/src/main/res/values-es/strings.xml
+++ b/src/main/res/values-es/strings.xml
@@ -1447,6 +1447,16 @@
     <string name="translation_do_not_ask_again">Permitir siempre el uso de Google Translate</string>
     <string name="translate_choose_language">Traducir a…</string>
     <string name="translate_more_languages">Más…</string>
+    <string name="pref_notify_new_stories">Nuevas historias</string>
+    <string name="pref_notify_new_stories_summary">Notificar cuando un contacto publique una nueva historia.</string>
+    <string name="pref_notify_new_blog_posts">Nuevas publicaciones del blog</string>
+    <string name="pref_notify_new_blog_posts_summary">Notificar cuando un contacto publique una nueva entrada de blog.</string>
+    <string name="new_stories_channel_name">Nuevas historias</string>
+    <string name="new_blog_posts_channel_name">Nuevas publicaciones del blog</string>
+    <string name="new_story_notification_text">publicó una nueva historia</string>
+    <string name="new_blog_post_notification_text">publicó una nueva entrada de blog</string>
+    <string name="new_blog_post_updated_notification_text">actualizó su entrada de blog</string>
+    <string name="blog_post_updated_prefix">Actualizado: %1$s</string>
     <string name="ephemeral_upload_fallback_title">¿Enviar sin eliminación automática?</string>
     <string name="ephemeral_upload_fallback_explained">El servidor de este contacto no admite archivos adjuntos en mensajes efímeros. El archivo se subirá normalmente y no se eliminará automáticamente.</string>
     <string name="ephemeral_upload_fallback_allow">Enviar de todos modos</string>
diff --git a/src/main/res/values-et/strings.xml b/src/main/res/values-et/strings.xml
index 222806b..9ca222f 100644
--- a/src/main/res/values-et/strings.xml
+++ b/src/main/res/values-et/strings.xml
@@ -1448,6 +1448,16 @@
     <string name="translation_do_not_ask_again">Luba alati varuvariandina Google Tõlge</string>
     <string name="translate_choose_language">Tõlgi keelde…</string>
     <string name="translate_more_languages">Rohkem…</string>
+    <string name="pref_notify_new_stories">Uued lood</string>
+    <string name="pref_notify_new_stories_summary">Teavita, kui kontakt postitab uue loo.</string>
+    <string name="pref_notify_new_blog_posts">Uued blogipostitused</string>
+    <string name="pref_notify_new_blog_posts_summary">Teavita, kui kontakt avaldab uue blogipostituse.</string>
+    <string name="new_stories_channel_name">Uued lood</string>
+    <string name="new_blog_posts_channel_name">Uued blogipostitused</string>
+    <string name="new_story_notification_text">postitas uue loo</string>
+    <string name="new_blog_post_notification_text">avaldas uue blogipostituse</string>
+    <string name="new_blog_post_updated_notification_text">uuendas oma blogipostitust</string>
+    <string name="blog_post_updated_prefix">Uuendatud: %1$s</string>
     <string name="ephemeral_upload_fallback_title">Kas saata ilma automaatse kustutamiseta?</string>
     <string name="ephemeral_upload_fallback_explained">Selle kontakti server ei toeta manuseid kaduvates sõnumites. Fail laaditakse üles tavapäraselt ega kustutata automaatselt.</string>
     <string name="ephemeral_upload_fallback_allow">Saada ikkagi</string>
diff --git a/src/main/res/values-fi/strings.xml b/src/main/res/values-fi/strings.xml
index 7e79ca9..f63f2f0 100644
--- a/src/main/res/values-fi/strings.xml
+++ b/src/main/res/values-fi/strings.xml
@@ -1406,6 +1406,16 @@
     <string name="translation_do_not_ask_again">Salli aina varautuminen Google Kääntäjään</string>
     <string name="translate_choose_language">Käännä kielelle…</string>
     <string name="translate_more_languages">Lisää…</string>
+    <string name="pref_notify_new_stories">Uudet tarinat</string>
+    <string name="pref_notify_new_stories_summary">Ilmoita, kun yhteystieto julkaisee uuden tarinan.</string>
+    <string name="pref_notify_new_blog_posts">Uudet blogikirjoitukset</string>
+    <string name="pref_notify_new_blog_posts_summary">Ilmoita, kun yhteystieto julkaisee uuden blogikirjoituksen.</string>
+    <string name="new_stories_channel_name">Uudet tarinat</string>
+    <string name="new_blog_posts_channel_name">Uudet blogikirjoitukset</string>
+    <string name="new_story_notification_text">julkaisi uuden tarinan</string>
+    <string name="new_blog_post_notification_text">julkaisi uuden blogikirjoituksen</string>
+    <string name="new_blog_post_updated_notification_text">päivitti blogikirjoituksensa</string>
+    <string name="blog_post_updated_prefix">Päivitetty: %1$s</string>
     <string name="ephemeral_upload_fallback_title">Lähetetäänkö ilman automaattista poistoa?</string>
     <string name="ephemeral_upload_fallback_explained">Tämän yhteystiedon palvelin ei tue liitteitä katoavissa viesteissä. Tiedosto ladataan normaalisti eikä sitä poisteta automaattisesti.</string>
     <string name="ephemeral_upload_fallback_allow">Lähetä silti</string>
diff --git a/src/main/res/values-fr/strings.xml b/src/main/res/values-fr/strings.xml
index b242e21..c5fb814 100644
--- a/src/main/res/values-fr/strings.xml
+++ b/src/main/res/values-fr/strings.xml
@@ -1439,6 +1439,16 @@
     <string name="translation_do_not_ask_again">Toujours autoriser le recours à Google Traduction</string>
     <string name="translate_choose_language">Traduire en…</string>
     <string name="translate_more_languages">Plus…</string>
+    <string name="pref_notify_new_stories">Nouvelles stories</string>
+    <string name="pref_notify_new_stories_summary">Notifier quand un contact publie une nouvelle story.</string>
+    <string name="pref_notify_new_blog_posts">Nouveaux articles de blog</string>
+    <string name="pref_notify_new_blog_posts_summary">Notifier quand un contact publie un nouvel article de blog.</string>
+    <string name="new_stories_channel_name">Nouvelles stories</string>
+    <string name="new_blog_posts_channel_name">Nouveaux articles de blog</string>
+    <string name="new_story_notification_text">a publié une nouvelle story</string>
+    <string name="new_blog_post_notification_text">a publié un nouvel article de blog</string>
+    <string name="new_blog_post_updated_notification_text">a mis à jour son article de blog</string>
+    <string name="blog_post_updated_prefix">Mis à jour : %1$s</string>
     <string name="ephemeral_upload_fallback_title">Envoyer sans suppression automatique ?</string>
     <string name="ephemeral_upload_fallback_explained">Le serveur de ce contact ne prend pas en charge les pièces jointes dans les messages éphémères. Le fichier sera envoyé normalement et ne sera pas supprimé automatiquement.</string>
     <string name="ephemeral_upload_fallback_allow">Envoyer quand même</string>
diff --git a/src/main/res/values-it/strings.xml b/src/main/res/values-it/strings.xml
index 6b090db..b5a635d 100644
--- a/src/main/res/values-it/strings.xml
+++ b/src/main/res/values-it/strings.xml
@@ -1449,6 +1449,16 @@
     <string name="translation_do_not_ask_again">Consenti sempre il ricorso a Google Traduttore</string>
     <string name="translate_choose_language">Traduci in…</string>
     <string name="translate_more_languages">Altro…</string>
+    <string name="pref_notify_new_stories">Nuove storie</string>
+    <string name="pref_notify_new_stories_summary">Notifica quando un contatto pubblica una nuova storia.</string>
+    <string name="pref_notify_new_blog_posts">Nuovi post del blog</string>
+    <string name="pref_notify_new_blog_posts_summary">Notifica quando un contatto pubblica un nuovo post sul blog.</string>
+    <string name="new_stories_channel_name">Nuove storie</string>
+    <string name="new_blog_posts_channel_name">Nuovi post del blog</string>
+    <string name="new_story_notification_text">ha pubblicato una nuova storia</string>
+    <string name="new_blog_post_notification_text">ha pubblicato un nuovo post sul blog</string>
+    <string name="new_blog_post_updated_notification_text">ha aggiornato il suo post sul blog</string>
+    <string name="blog_post_updated_prefix">Aggiornato: %1$s</string>
     <string name="ephemeral_upload_fallback_title">Inviare senza eliminazione automatica?</string>
     <string name="ephemeral_upload_fallback_explained">Il server di questo contatto non supporta gli allegati nei messaggi effimeri. Il file verrà caricato normalmente e non verrà eliminato automaticamente.</string>
     <string name="ephemeral_upload_fallback_allow">Invia comunque</string>
diff --git a/src/main/res/values-ja/strings.xml b/src/main/res/values-ja/strings.xml
index 3d61f56..731f005 100644
--- a/src/main/res/values-ja/strings.xml
+++ b/src/main/res/values-ja/strings.xml
@@ -1404,6 +1404,16 @@
     <string name="translation_do_not_ask_again">常にGoogle翻訳へのフォールバックを許可</string>
     <string name="translate_choose_language">翻訳先…</string>
     <string name="translate_more_languages">その他…</string>
+    <string name="pref_notify_new_stories">新しいストーリー</string>
+    <string name="pref_notify_new_stories_summary">連絡先が新しいストーリーを投稿したときに通知します。</string>
+    <string name="pref_notify_new_blog_posts">新しいブログ投稿</string>
+    <string name="pref_notify_new_blog_posts_summary">連絡先が新しいブログ投稿を公開したときに通知します。</string>
+    <string name="new_stories_channel_name">新しいストーリー</string>
+    <string name="new_blog_posts_channel_name">新しいブログ投稿</string>
+    <string name="new_story_notification_text">新しいストーリーを投稿しました</string>
+    <string name="new_blog_post_notification_text">新しいブログ記事を公開しました</string>
+    <string name="new_blog_post_updated_notification_text">ブログ投稿を更新しました</string>
+    <string name="blog_post_updated_prefix">更新: %1$s</string>
     <string name="ephemeral_upload_fallback_title">自動削除なしで送信しますか?</string>
     <string name="ephemeral_upload_fallback_explained">この連絡先のサーバーは消える添付ファイルに対応していません。ファイルは通常どおりアップロードされ、自動的には削除されません。</string>
     <string name="ephemeral_upload_fallback_allow">このまま送信</string>
diff --git a/src/main/res/values-nl/strings.xml b/src/main/res/values-nl/strings.xml
index 8692708..65a3fd5 100644
--- a/src/main/res/values-nl/strings.xml
+++ b/src/main/res/values-nl/strings.xml
@@ -1424,6 +1424,16 @@
     <string name="translation_do_not_ask_again">Terugvallen op Google Translate altijd toestaan</string>
     <string name="translate_choose_language">Vertalen naar…</string>
     <string name="translate_more_languages">Meer…</string>
+    <string name="pref_notify_new_stories">Nieuwe stories</string>
+    <string name="pref_notify_new_stories_summary">Melden wanneer een contact een nieuwe story plaatst.</string>
+    <string name="pref_notify_new_blog_posts">Nieuwe blogberichten</string>
+    <string name="pref_notify_new_blog_posts_summary">Melden wanneer een contact een nieuw blogbericht plaatst.</string>
+    <string name="new_stories_channel_name">Nieuwe stories</string>
+    <string name="new_blog_posts_channel_name">Nieuwe blogberichten</string>
+    <string name="new_story_notification_text">heeft een nieuwe story geplaatst</string>
+    <string name="new_blog_post_notification_text">heeft een nieuw blogbericht geplaatst</string>
+    <string name="new_blog_post_updated_notification_text">heeft het blogbericht bijgewerkt</string>
+    <string name="blog_post_updated_prefix">Bijgewerkt: %1$s</string>
     <string name="ephemeral_upload_fallback_title">Verzenden zonder automatisch verwijderen?</string>
     <string name="ephemeral_upload_fallback_explained">De server van dit contact ondersteunt geen bijlagen bij verlopende berichten. Het bestand wordt normaal geüpload en wordt niet automatisch verwijderd.</string>
     <string name="ephemeral_upload_fallback_allow">Toch verzenden</string>
diff --git a/src/main/res/values-pl/strings.xml b/src/main/res/values-pl/strings.xml
index 6d39d1c..f747135 100644
--- a/src/main/res/values-pl/strings.xml
+++ b/src/main/res/values-pl/strings.xml
@@ -1462,6 +1462,16 @@
     <string name="translation_do_not_ask_again">Zawsze zezwalaj na korzystanie z Tłumacza Google</string>
     <string name="translate_choose_language">Przetłumacz na…</string>
     <string name="translate_more_languages">Więcej…</string>
+    <string name="pref_notify_new_stories">Nowe relacje</string>
+    <string name="pref_notify_new_stories_summary">Powiadamiaj, gdy kontakt opublikuje nową relację.</string>
+    <string name="pref_notify_new_blog_posts">Nowe wpisy na blogu</string>
+    <string name="pref_notify_new_blog_posts_summary">Powiadamiaj, gdy kontakt opublikuje nowy wpis na blogu.</string>
+    <string name="new_stories_channel_name">Nowe relacje</string>
+    <string name="new_blog_posts_channel_name">Nowe wpisy na blogu</string>
+    <string name="new_story_notification_text">opublikował(a) nową relację</string>
+    <string name="new_blog_post_notification_text">opublikował(a) nowy wpis na blogu</string>
+    <string name="new_blog_post_updated_notification_text">zaktualizował(a) wpis na blogu</string>
+    <string name="blog_post_updated_prefix">Zaktualizowano: %1$s</string>
     <string name="ephemeral_upload_fallback_title">Wysłać bez automatycznego usuwania?</string>
     <string name="ephemeral_upload_fallback_explained">Serwer tego kontaktu nie obsługuje załączników w znikających wiadomościach. Plik zostanie przesłany normalnie i nie zostanie automatycznie usunięty.</string>
     <string name="ephemeral_upload_fallback_allow">Wyślij mimo to</string>
diff --git a/src/main/res/values-pt-rBR/strings.xml b/src/main/res/values-pt-rBR/strings.xml
index e663d6a..870fbf8 100644
--- a/src/main/res/values-pt-rBR/strings.xml
+++ b/src/main/res/values-pt-rBR/strings.xml
@@ -1439,6 +1439,16 @@
     <string name="translation_do_not_ask_again">Permitir sempre o recurso ao Google Tradutor</string>
     <string name="translate_choose_language">Traduzir para…</string>
     <string name="translate_more_languages">Mais…</string>
+    <string name="pref_notify_new_stories">Novos stories</string>
+    <string name="pref_notify_new_stories_summary">Notificar quando um contato publicar um novo story.</string>
+    <string name="pref_notify_new_blog_posts">Novas publicações do blog</string>
+    <string name="pref_notify_new_blog_posts_summary">Notificar quando um contato publicar uma nova publicação no blog.</string>
+    <string name="new_stories_channel_name">Novos stories</string>
+    <string name="new_blog_posts_channel_name">Novas publicações do blog</string>
+    <string name="new_story_notification_text">publicou um novo story</string>
+    <string name="new_blog_post_notification_text">publicou uma nova publicação no blog</string>
+    <string name="new_blog_post_updated_notification_text">atualizou a publicação no blog</string>
+    <string name="blog_post_updated_prefix">Atualizado: %1$s</string>
     <string name="ephemeral_upload_fallback_title">Enviar sem exclusão automática?</string>
     <string name="ephemeral_upload_fallback_explained">O servidor deste contato não oferece suporte a anexos em mensagens efêmeras. O arquivo será enviado normalmente e não será excluído automaticamente.</string>
     <string name="ephemeral_upload_fallback_allow">Enviar mesmo assim</string>
diff --git a/src/main/res/values-pt/strings.xml b/src/main/res/values-pt/strings.xml
index 0b57e8b..fcfe89a 100644
--- a/src/main/res/values-pt/strings.xml
+++ b/src/main/res/values-pt/strings.xml
@@ -1439,6 +1439,16 @@
     <string name="translation_do_not_ask_again">Permitir sempre o recurso ao Google Tradutor</string>
     <string name="translate_choose_language">Traduzir para…</string>
     <string name="translate_more_languages">Mais…</string>
+    <string name="pref_notify_new_stories">Novos stories</string>
+    <string name="pref_notify_new_stories_summary">Notificar quando um contacto publicar um novo story.</string>
+    <string name="pref_notify_new_blog_posts">Novas publicações do blog</string>
+    <string name="pref_notify_new_blog_posts_summary">Notificar quando um contacto publicar uma nova publicação no blog.</string>
+    <string name="new_stories_channel_name">Novos stories</string>
+    <string name="new_blog_posts_channel_name">Novas publicações do blog</string>
+    <string name="new_story_notification_text">publicou um novo story</string>
+    <string name="new_blog_post_notification_text">publicou uma nova publicação no blog</string>
+    <string name="new_blog_post_updated_notification_text">atualizou a sua publicação no blog</string>
+    <string name="blog_post_updated_prefix">Atualizado: %1$s</string>
     <string name="ephemeral_upload_fallback_title">Enviar sem eliminação automática?</string>
     <string name="ephemeral_upload_fallback_explained">O servidor deste contacto não suporta anexos em mensagens efémeras. O ficheiro será carregado normalmente e não será eliminado automaticamente.</string>
     <string name="ephemeral_upload_fallback_allow">Enviar mesmo assim</string>
diff --git a/src/main/res/values-ro-rRO/strings.xml b/src/main/res/values-ro-rRO/strings.xml
index 97d0304..e47b290 100644
--- a/src/main/res/values-ro-rRO/strings.xml
+++ b/src/main/res/values-ro-rRO/strings.xml
@@ -1450,6 +1450,16 @@
     <string name="translation_do_not_ask_again">Permite întotdeauna revenirea la Google Traducere</string>
     <string name="translate_choose_language">Traduceți în…</string>
     <string name="translate_more_languages">Mai mult…</string>
+    <string name="pref_notify_new_stories">Povești noi</string>
+    <string name="pref_notify_new_stories_summary">Notifică atunci când un contact publică o poveste nouă.</string>
+    <string name="pref_notify_new_blog_posts">Postări noi pe blog</string>
+    <string name="pref_notify_new_blog_posts_summary">Notifică atunci când un contact publică o postare nouă pe blog.</string>
+    <string name="new_stories_channel_name">Povești noi</string>
+    <string name="new_blog_posts_channel_name">Postări noi pe blog</string>
+    <string name="new_story_notification_text">a publicat o poveste nouă</string>
+    <string name="new_blog_post_notification_text">a publicat o postare nouă pe blog</string>
+    <string name="new_blog_post_updated_notification_text">a actualizat postarea pe blog</string>
+    <string name="blog_post_updated_prefix">Actualizat: %1$s</string>
     <string name="ephemeral_upload_fallback_title">Trimiteți fără ștergere automată?</string>
     <string name="ephemeral_upload_fallback_explained">Serverul acestui contact nu acceptă atașamente în mesajele efemere. Fișierul va fi încărcat normal și nu va fi șters automat.</string>
     <string name="ephemeral_upload_fallback_allow">Trimite oricum</string>
diff --git a/src/main/res/values-ru/strings.xml b/src/main/res/values-ru/strings.xml
index 64f9894..876b040 100644
--- a/src/main/res/values-ru/strings.xml
+++ b/src/main/res/values-ru/strings.xml
@@ -1485,6 +1485,16 @@
     <string name="translation_do_not_ask_again">Всегда разрешать резервный Google Переводчик</string>
     <string name="translate_choose_language">Перевести на…</string>
     <string name="translate_more_languages">Ещё…</string>
+    <string name="pref_notify_new_stories">Новые истории</string>
+    <string name="pref_notify_new_stories_summary">Уведомлять, когда контакт публикует новую историю.</string>
+    <string name="pref_notify_new_blog_posts">Новые записи блога</string>
+    <string name="pref_notify_new_blog_posts_summary">Уведомлять, когда контакт публикует новую запись блога.</string>
+    <string name="new_stories_channel_name">Новые истории</string>
+    <string name="new_blog_posts_channel_name">Новые записи блога</string>
+    <string name="new_story_notification_text">опубликовал(а) новую историю</string>
+    <string name="new_blog_post_notification_text">опубликовал(а) новую запись блога</string>
+    <string name="new_blog_post_updated_notification_text">обновил(а) запись блога</string>
+    <string name="blog_post_updated_prefix">Обновлено: %1$s</string>
     <string name="ephemeral_upload_fallback_title">Отправить без автоудаления?</string>
     <string name="ephemeral_upload_fallback_explained">Сервер этого контакта не поддерживает вложения в исчезающих сообщениях. Файл будет загружен обычным способом и не будет удалён автоматически.</string>
     <string name="ephemeral_upload_fallback_allow">Всё равно отправить</string>
diff --git a/src/main/res/values-sr/strings.xml b/src/main/res/values-sr/strings.xml
index 15f94a2..1d7a0c7 100644
--- a/src/main/res/values-sr/strings.xml
+++ b/src/main/res/values-sr/strings.xml
@@ -1467,6 +1467,16 @@
     <string name="translation_do_not_ask_again">Увек дозволи резервни Google преводилац</string>
     <string name="translate_choose_language">Преведи на…</string>
     <string name="translate_more_languages">Више…</string>
+    <string name="pref_notify_new_stories">Нове приче</string>
+    <string name="pref_notify_new_stories_summary">Обавести када контакт објави нову причу.</string>
+    <string name="pref_notify_new_blog_posts">Нови објаве на блогу</string>
+    <string name="pref_notify_new_blog_posts_summary">Обавести када контакт објави нову објаву на блогу.</string>
+    <string name="new_stories_channel_name">Нове приче</string>
+    <string name="new_blog_posts_channel_name">Нови објаве на блогу</string>
+    <string name="new_story_notification_text">је објавио/ла нову причу</string>
+    <string name="new_blog_post_notification_text">је објавио/ла нову објаву на блогу</string>
+    <string name="new_blog_post_updated_notification_text">је ажурирао/ла објаву на блогу</string>
+    <string name="blog_post_updated_prefix">Ажурирано: %1$s</string>
     <string name="ephemeral_upload_fallback_title">Послати без аутоматског брисања?</string>
     <string name="ephemeral_upload_fallback_explained">Сервер овог контакта не подржава прилоге у нестајућим порукама. Датотека ће бити отпремљена на уобичајен начин и неће бити аутоматски обрисана.</string>
     <string name="ephemeral_upload_fallback_allow">Ипак пошаљи</string>
diff --git a/src/main/res/values-sv/strings.xml b/src/main/res/values-sv/strings.xml
index b1f3a8a..cc87a8c 100644
--- a/src/main/res/values-sv/strings.xml
+++ b/src/main/res/values-sv/strings.xml
@@ -1443,6 +1443,16 @@
     <string name="translation_do_not_ask_again">Tillåt alltid reserv till Google Translate</string>
     <string name="translate_choose_language">Översätt till…</string>
     <string name="translate_more_languages">Mer…</string>
+    <string name="pref_notify_new_stories">Nya stories</string>
+    <string name="pref_notify_new_stories_summary">Meddela när en kontakt publicerar en ny story.</string>
+    <string name="pref_notify_new_blog_posts">Nya blogginlägg</string>
+    <string name="pref_notify_new_blog_posts_summary">Meddela när en kontakt publicerar ett nytt blogginlägg.</string>
+    <string name="new_stories_channel_name">Nya stories</string>
+    <string name="new_blog_posts_channel_name">Nya blogginlägg</string>
+    <string name="new_story_notification_text">publicerade en ny story</string>
+    <string name="new_blog_post_notification_text">publicerade ett nytt blogginlägg</string>
+    <string name="new_blog_post_updated_notification_text">uppdaterade sitt blogginlägg</string>
+    <string name="blog_post_updated_prefix">Uppdaterad: %1$s</string>
     <string name="ephemeral_upload_fallback_title">Skicka utan automatisk radering?</string>
     <string name="ephemeral_upload_fallback_explained">Den här kontaktens server stöder inte bilagor i självförstörande meddelanden. Filen laddas upp som vanligt och raderas inte automatiskt.</string>
     <string name="ephemeral_upload_fallback_allow">Skicka ändå</string>
diff --git a/src/main/res/values-zh-rCN/strings.xml b/src/main/res/values-zh-rCN/strings.xml
index 4a64c3d..b3b7c18 100644
--- a/src/main/res/values-zh-rCN/strings.xml
+++ b/src/main/res/values-zh-rCN/strings.xml
@@ -1403,6 +1403,16 @@
     <string name="translation_do_not_ask_again">始终允许回退到 Google 翻译</string>
     <string name="translate_choose_language">翻译为…</string>
     <string name="translate_more_languages">更多…</string>
+    <string name="pref_notify_new_stories">新动态</string>
+    <string name="pref_notify_new_stories_summary">联系人发布新动态时通知我。</string>
+    <string name="pref_notify_new_blog_posts">新博客文章</string>
+    <string name="pref_notify_new_blog_posts_summary">联系人发布新博客文章时通知我。</string>
+    <string name="new_stories_channel_name">新动态</string>
+    <string name="new_blog_posts_channel_name">新博客文章</string>
+    <string name="new_story_notification_text">发布了新动态</string>
+    <string name="new_blog_post_notification_text">发布了新博客文章</string>
+    <string name="new_blog_post_updated_notification_text">更新了博客文章</string>
+    <string name="blog_post_updated_prefix">已更新:%1$s</string>
     <string name="ephemeral_upload_fallback_title">在没有自动删除的情况下发送?</string>
     <string name="ephemeral_upload_fallback_explained">此联系人的服务器不支持阅后即焚消息中的附件。文件将正常上传,且不会自动删除。</string>
     <string name="ephemeral_upload_fallback_allow">仍然发送</string>
diff --git a/src/main/res/values-zh-rTW/strings.xml b/src/main/res/values-zh-rTW/strings.xml
index a09cc3a..617ca8c 100644
--- a/src/main/res/values-zh-rTW/strings.xml
+++ b/src/main/res/values-zh-rTW/strings.xml
@@ -1413,6 +1413,16 @@
     <string name="translation_do_not_ask_again">永遠允許回退至 Google 翻譯</string>
     <string name="translate_choose_language">翻譯為…</string>
     <string name="translate_more_languages">更多…</string>
+    <string name="pref_notify_new_stories">新限時動態</string>
+    <string name="pref_notify_new_stories_summary">聯絡人發布新限時動態時通知我。</string>
+    <string name="pref_notify_new_blog_posts">新網誌文章</string>
+    <string name="pref_notify_new_blog_posts_summary">聯絡人發布新網誌文章時通知我。</string>
+    <string name="new_stories_channel_name">新限時動態</string>
+    <string name="new_blog_posts_channel_name">新網誌文章</string>
+    <string name="new_story_notification_text">發布了新限時動態</string>
+    <string name="new_blog_post_notification_text">發布了新網誌文章</string>
+    <string name="new_blog_post_updated_notification_text">更新了網誌文章</string>
+    <string name="blog_post_updated_prefix">已更新:%1$s</string>
     <string name="ephemeral_upload_fallback_title">要在沒有自動刪除的情況下傳送嗎?</string>
     <string name="ephemeral_upload_fallback_explained">此聯絡人的伺服器不支援閱後即焚訊息中的附件。檔案將正常上傳,且不會自動刪除。</string>
     <string name="ephemeral_upload_fallback_allow">仍要傳送</string>
diff --git a/src/main/res/values/defaults.xml b/src/main/res/values/defaults.xml
index af978e3..b6f201d 100644
--- a/src/main/res/values/defaults.xml
+++ b/src/main/res/values/defaults.xml
@@ -3,6 +3,8 @@
     <bool name="portrait_only">true</bool>
     <bool name="enter_is_send">false</bool>
     <bool name="notifications_from_strangers">true</bool>
+    <bool name="notify_new_stories">true</bool>
+    <bool name="notify_new_blog_posts">true</bool>
     <bool name="headsup_notifications">false</bool>
     <bool name="dnd_sync_system">false</bool>
     <bool name="dnd_include_silent_modes">false</bool>
diff --git a/src/main/res/values/strings.xml b/src/main/res/values/strings.xml
index 2fec653..d29eb56 100644
--- a/src/main/res/values/strings.xml
+++ b/src/main/res/values/strings.xml
@@ -754,6 +754,10 @@
     <string name="contact_blocked_past_tense">Contact blocked.</string>
     <string name="pref_notifications_from_strangers">Notifications from strangers</string>
     <string name="pref_notifications_from_strangers_summary">Notify for messages and calls received from strangers.</string>
+    <string name="pref_notify_new_stories">New stories</string>
+    <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="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>
@@ -860,6 +864,12 @@
     <string name="silent_messages_channel_name">Silent messages</string>
     <string name="silent_messages_channel_description">This notification group is used to display notifications that should not trigger any sound. For example when being active on another device (Grace Period).</string>
     <string name="delivery_failed_channel_name">Failed deliveries</string>
+    <string name="new_stories_channel_name">New stories</string>
+    <string name="new_blog_posts_channel_name">New blog posts</string>
+    <string name="new_story_notification_text">posted a new story</string>
+    <string name="new_blog_post_notification_text">published a new blog post</string>
+    <string name="new_blog_post_updated_notification_text">updated their blog post</string>
+    <string name="blog_post_updated_prefix">Updated: %1$s</string>
     <string name="pref_message_notification_settings">Message notification settings</string>
     <string name="pref_incoming_call_notification_settings">Incoming calls notification settings</string>
     <string name="pref_more_notification_settings_summary">Importance, Sound, Vibrate</string>
diff --git a/src/main/res/xml/preferences_notifications.xml b/src/main/res/xml/preferences_notifications.xml
index c02633c..51d153f 100644
--- a/src/main/res/xml/preferences_notifications.xml
+++ b/src/main/res/xml/preferences_notifications.xml
@@ -69,6 +69,16 @@
         android:key="notifications_from_strangers"
         android:summary="@string/pref_notifications_from_strangers_summary"
         android:title="@string/pref_notifications_from_strangers" />
+    <SwitchPreferenceCompat
+        android:defaultValue="@bool/notify_new_stories"
+        android:key="notify_new_stories"
+        android:summary="@string/pref_notify_new_stories_summary"
+        android:title="@string/pref_notify_new_stories" />
+    <SwitchPreferenceCompat
+        android:defaultValue="@bool/notify_new_blog_posts"
+        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/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.