🔀 Commit

bug fixes and improved stories
Commitb28f900bb2923855af7f3b70a02fb78db9b2f30c
AuthorJabJab <noreply@xmpp.tel>
Date2026-07-05
Parentfea28698
commit b28f900bb2923855af7f3b70a02fb78db9b2f30c
Author: JabJab <noreply@xmpp.tel>
Date:   Sun Jul 5 22:14:50 2026 +0300

    bug fixes and improved stories
---
 build.gradle                                       |   4 +-
 .../android/xmpp/model/stories/Story.java          |   9 +
 .../xmpp/processor/AccountStateProcessor.java      |  11 +
 .../xmpp/jabjab/persistance/DatabaseBackend.java   |  95 ++++++-
 .../jabjab/services/XmppConnectionService.java     |  56 ++++-
 src/main/java/tel/xmpp/jabjab/ui/BlogActivity.java |   5 +
 .../java/tel/xmpp/jabjab/ui/BottomNavHelper.java   |  34 ++-
 .../java/tel/xmpp/jabjab/ui/GifSearchActivity.java |   6 +
 .../tel/xmpp/jabjab/ui/ImportBackupActivity.java   |  47 ++++
 .../java/tel/xmpp/jabjab/ui/StoriesActivity.java   | 272 +++++++++------------
 .../java/tel/xmpp/jabjab/ui/StoryViewActivity.java | 105 +++++++-
 .../tel/xmpp/jabjab/ui/adapter/MessageAdapter.java |  96 +++++++-
 .../xmpp/jabjab/ui/util/ChannelMessageParser.java  |  28 ++-
 .../tel/xmpp/jabjab/ui/util/UpdateChecker.java     |   5 +-
 .../jabjab/ui/widget/ClickableMovementMethod.java  |  17 +-
 src/main/java/tel/xmpp/jabjab/utils/GeoHelper.java |   5 +-
 src/main/java/tel/xmpp/jabjab/utils/UIHelper.java  |  36 +++
 .../tel/xmpp/jabjab/worker/ExportBackupWorker.java |  17 +-
 .../tel/xmpp/jabjab/worker/ImportBackupWorker.java |  53 ++--
 .../java/tel/xmpp/jabjab/xmpp/XmppConnection.java  |  14 +-
 .../tel/xmpp/jabjab/xmpp/manager/BlogManager.java  |  29 ++-
 .../xmpp/jabjab/xmpp/manager/PubSubManager.java    |   7 +
 .../xmpp/jabjab/xmpp/manager/StoriesManager.java   | 100 +++++++-
 src/main/res/layout/activity_import_backup.xml     |  11 +
 src/main/res/layout/activity_story_view.xml        |  77 ++++--
 src/main/res/layout/item_message_channel_card.xml  |  11 +
 src/main/res/values/strings.xml                    |   3 +
 27 files changed, 919 insertions(+), 234 deletions(-)

diff --git a/build.gradle b/build.gradle
index 384b6ae..2d64a09 100644
--- a/build.gradle
+++ b/build.gradle
@@ -113,8 +113,8 @@ android {
 
     defaultConfig {
         minSdkVersion 23
-        versionCode 42284
-        versionName "1.0.0"
+        versionCode 42288
+        versionName "1.0.1"
         applicationId "tel.xmpp.jabjab"
         resValue "string", "applicationId", applicationId
         def appName = "JabJab"
diff --git a/src/main/java/im/conversations/android/xmpp/model/stories/Story.java b/src/main/java/im/conversations/android/xmpp/model/stories/Story.java
index fa17df3..61e130e 100644
--- a/src/main/java/im/conversations/android/xmpp/model/stories/Story.java
+++ b/src/main/java/im/conversations/android/xmpp/model/stories/Story.java
@@ -70,9 +70,18 @@ public class Story {
         return entry;
     }
 
+    /** Reconstruct a Story from fields stored in the local DB cache. */
+    public static Story of(final String url, final String mime,
+                           final String caption, final String published) {
+        if (url == null || url.isEmpty()) return null;
+        return new Story(url, mime, caption, published);
+    }
+
     public String getUrl() { return url; }
     public String getMime() { return mime; }
     public String getCaption() { return caption; }
+    /** Raw ISO-8601 published string as stored in the Atom entry / DB cache. */
+    public String getPublishedRaw() { return published; }
 
     /** Derives expiry as published + 24h (mirrors server-side item_expire). */
     public String getExpires() {
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 c81c520..cf1c8b4 100644
--- a/src/main/java/im/conversations/android/xmpp/processor/AccountStateProcessor.java
+++ b/src/main/java/im/conversations/android/xmpp/processor/AccountStateProcessor.java
@@ -8,9 +8,11 @@ import tel.xmpp.jabjab.entities.Account;
 import tel.xmpp.jabjab.http.ServiceOutageStatus;
 import tel.xmpp.jabjab.services.XmppConnectionService;
 import tel.xmpp.jabjab.xmpp.XmppConnection;
+import tel.xmpp.jabjab.entities.Contact;
 import tel.xmpp.jabjab.xmpp.manager.ClientStateIndicationManager;
 import tel.xmpp.jabjab.xmpp.manager.JingleManager;
 import tel.xmpp.jabjab.xmpp.manager.MultiUserChatManager;
+import tel.xmpp.jabjab.xmpp.manager.StoriesManager;
 import java.util.concurrent.TimeUnit;
 import java.util.function.Consumer;
 
@@ -65,6 +67,15 @@ public class AccountStateProcessor extends XmppConnection.Delegate
             }
             this.service.scheduleWakeUpCall(
                     Config.PING_MAX_INTERVAL * 1000L, account.getUuid().hashCode());
+            // Background story prefetch — mirror Monocles: fetch own stories and all
+            // TO-subscribed contacts' stories so StoriesActivity can load from DB instantly.
+            final StoriesManager storiesManager = getManager(StoriesManager.class);
+            storiesManager.fetchAndCacheSelf();
+            for (final Contact contact : account.getRoster().getContacts()) {
+                if (contact.getOption(Contact.Options.TO)) {
+                    storiesManager.fetchAndCache(contact.getAddress());
+                }
+            }
         } else if (account.getStatus() == Account.State.OFFLINE
                 || account.getStatus() == Account.State.DISABLED
                 || account.getStatus() == Account.State.LOGGED_OUT) {
diff --git a/src/main/java/tel/xmpp/jabjab/persistance/DatabaseBackend.java b/src/main/java/tel/xmpp/jabjab/persistance/DatabaseBackend.java
index eeb0c12..3ddd56f 100644
--- a/src/main/java/tel/xmpp/jabjab/persistance/DatabaseBackend.java
+++ b/src/main/java/tel/xmpp/jabjab/persistance/DatabaseBackend.java
@@ -53,10 +53,13 @@ import java.security.cert.CertificateFactory;
 import java.security.cert.X509Certificate;
 import java.time.Instant;
 import java.util.ArrayList;
+import im.conversations.android.xmpp.model.stories.Story;
+import java.util.ArrayList;
 import java.util.Collection;
 import java.util.Collections;
 import java.util.HashMap;
 import java.util.HashSet;
+import java.util.LinkedHashMap;
 import java.util.List;
 import java.util.Map;
 import java.util.Objects;
@@ -77,7 +80,19 @@ import org.whispersystems.libsignal.state.SignedPreKeyRecord;
 public class DatabaseBackend extends SQLiteOpenHelper {
 
     private static final String DATABASE_NAME = "history";
-    private static final int DATABASE_VERSION = 58;
+    private static final int DATABASE_VERSION = 59;
+
+    private static final String CREATE_STORY_CACHE_TABLE =
+            "CREATE TABLE IF NOT EXISTS story_cache ("
+                    + "item_id     TEXT NOT NULL,"
+                    + "account_jid TEXT NOT NULL,"
+                    + "contact_jid TEXT NOT NULL,"
+                    + "url         TEXT NOT NULL,"
+                    + "mime_type   TEXT,"
+                    + "caption     TEXT,"
+                    + "published   TEXT,"
+                    + "PRIMARY KEY (item_id, account_jid)"
+                    + ")";
 
     private static boolean requiresMessageIndexRebuild = false;
     private static DatabaseBackend instance = null;
@@ -530,6 +545,7 @@ public class DatabaseBackend extends SQLiteOpenHelper {
         db.execSQL(CREATE_MESSAGE_INSERT_TRIGGER);
         db.execSQL(CREATE_MESSAGE_UPDATE_TRIGGER);
         db.execSQL(CREATE_MESSAGE_DELETE_TRIGGER);
+        db.execSQL(CREATE_STORY_CACHE_TABLE);
         db.execSQL(CREATE_CAPS_CACHE_TABLE);
         db.execSQL(CREATE_CAPS_CACHE_INDEX_CAPS);
         db.execSQL(CREATE_CAPS_CACHE_INDEX_CAPS2);
@@ -1143,6 +1159,9 @@ public class DatabaseBackend extends SQLiteOpenHelper {
                             + " INTEGER DEFAULT 0");
             db.execSQL(CREATE_MESSAGE_EXPIRE_AT_INDEX);
         }
+        if (oldVersion < 59 && newVersion >= 59) {
+            db.execSQL(CREATE_STORY_CACHE_TABLE);
+        }
     }
 
     private void canonicalizeJids(SQLiteDatabase db) {
@@ -2824,6 +2843,80 @@ public class DatabaseBackend extends SQLiteOpenHelper {
         return builder.build();
     }
 
+    // ── Story cache ────────────────────────────────────────────────────────────
+
+    /**
+     * Replaces all cached stories for a given (accountJid, contactJid) pair with
+     * the new set, then purges entries whose published timestamp is > 24 h old.
+     * The replace-all approach keeps the cache authoritative: retractions on the
+     * server are reflected naturally when the next fetch returns fewer items.
+     */
+    public void replaceStoryCacheForContact(final String accountJid, final String contactJid,
+            final Map<String, Story> stories) {
+        final SQLiteDatabase db = getWritableDatabase();
+        db.beginTransaction();
+        try {
+            db.delete("story_cache",
+                    "account_jid = ? AND contact_jid = ?",
+                    new String[]{accountJid, contactJid});
+            for (final Map.Entry<String, Story> e : stories.entrySet()) {
+                final Story s = e.getValue();
+                if (s == null || !s.isLive()) continue;
+                final ContentValues cv = new ContentValues();
+                cv.put("item_id",     e.getKey());
+                cv.put("account_jid", accountJid);
+                cv.put("contact_jid", contactJid);
+                cv.put("url",         s.getUrl());
+                cv.put("mime_type",   s.getMime());
+                cv.put("caption",     s.getCaption());
+                cv.put("published",   s.getPublishedRaw());
+                db.insertWithOnConflict("story_cache", null, cv,
+                        SQLiteDatabase.CONFLICT_REPLACE);
+            }
+            db.setTransactionSuccessful();
+        } finally {
+            db.endTransaction();
+        }
+        // clean up globally expired entries from any contact
+        db.delete("story_cache", "published IS NOT NULL AND published < ?",
+                new String[]{
+                        java.time.Instant.now()
+                                .minus(24, java.time.temporal.ChronoUnit.HOURS)
+                                .toString()
+                });
+    }
+
+    /**
+     * Returns all live cached stories for an account, grouped by contact JID.
+     * Key = contact bare JID, value = ordered map of itemId → Story.
+     */
+    public Map<String, LinkedHashMap<String, Story>> getStoryCacheForAccount(
+            final String accountJid) {
+        final Map<String, LinkedHashMap<String, Story>> result = new LinkedHashMap<>();
+        final SQLiteDatabase db = getReadableDatabase();
+        final Cursor c = db.query("story_cache",
+                new String[]{"item_id", "contact_jid", "url", "mime_type", "caption", "published"},
+                "account_jid = ?", new String[]{accountJid},
+                null, null, "contact_jid ASC, published DESC");
+        try {
+            while (c.moveToNext()) {
+                final String itemId    = c.getString(0);
+                final String contactJid = c.getString(1);
+                final String url       = c.getString(2);
+                final String mime      = c.getString(3);
+                final String caption   = c.getString(4);
+                final String published = c.getString(5);
+                final Story story = Story.of(url, mime, caption, published);
+                if (story == null || !story.isLive()) continue;
+                result.computeIfAbsent(contactJid, k -> new LinkedHashMap<>())
+                        .put(itemId, story);
+            }
+        } finally {
+            c.close();
+        }
+        return result;
+    }
+
     public record AccountWithOptions(Jid jid, int options) {
         public boolean isOptionSet(final int option) {
             return ((options & (1 << option)) != 0);
diff --git a/src/main/java/tel/xmpp/jabjab/services/XmppConnectionService.java b/src/main/java/tel/xmpp/jabjab/services/XmppConnectionService.java
index 12cbda2..0c7938f 100644
--- a/src/main/java/tel/xmpp/jabjab/services/XmppConnectionService.java
+++ b/src/main/java/tel/xmpp/jabjab/services/XmppConnectionService.java
@@ -240,13 +240,24 @@ public class XmppConnectionService extends Service {
     public OnContactStatusChanged onContactStatusChanged =
             (contact, online) -> {
                 final var conversation = find(contact);
-                if (conversation == null) {
-                    return;
-                }
                 if (online) {
-                    if (contact.getPresences().size() == 1) {
+                    if (conversation != null && contact.getPresences().size() == 1) {
                         sendUnsentMessages(conversation);
                     }
+                    // Mirror Monocles: fetch this contact's stories whenever they come online
+                    // so the cache stays fresh without the user having to open StoriesActivity.
+                    if (contact.getOption(tel.xmpp.jabjab.entities.Contact.Options.TO)) {
+                        try {
+                            final var connection = contact.getAccount().getXmppConnection();
+                            if (connection != null) {
+                                connection.getManager(
+                                        tel.xmpp.jabjab.xmpp.manager.StoriesManager.class)
+                                        .fetchAndCache(contact.getAddress());
+                            }
+                        } catch (final Exception e) {
+                            Log.d(Config.LOGTAG, "story prefetch on presence failed: " + e.getMessage());
+                        }
+                    }
                 }
             };
     private List<Account> accounts;
@@ -267,6 +278,8 @@ public class XmppConnectionService extends Service {
             Collections.newSetFromMap(new WeakHashMap<OnShowErrorToast, Boolean>());
     private final Set<OnAccountUpdate> mOnAccountUpdates =
             Collections.newSetFromMap(new WeakHashMap<OnAccountUpdate, Boolean>());
+    private final Set<OnStoriesUpdate> mOnStoriesUpdates =
+            Collections.newSetFromMap(new WeakHashMap<OnStoriesUpdate, Boolean>());
     private final Set<OnCaptchaRequested> mOnCaptchaRequested =
             Collections.newSetFromMap(new WeakHashMap<OnCaptchaRequested, Boolean>());
     private final Set<OnRosterUpdate> mOnRosterUpdates =
@@ -2933,6 +2946,28 @@ public class XmppConnectionService extends Service {
         }
     }
 
+    public void setOnStoriesUpdateListener(final OnStoriesUpdate listener) {
+        final boolean remainingListeners;
+        synchronized (LISTENER_LOCK) {
+            remainingListeners = checkListeners();
+            this.mOnStoriesUpdates.add(listener);
+        }
+        if (remainingListeners) {
+            switchToForeground();
+        }
+    }
+
+    public void removeOnStoriesUpdateListener(final OnStoriesUpdate listener) {
+        final boolean remainingListeners;
+        synchronized (LISTENER_LOCK) {
+            this.mOnStoriesUpdates.remove(listener);
+            remainingListeners = checkListeners();
+        }
+        if (remainingListeners) {
+            switchToBackground();
+        }
+    }
+
     public void setOnCaptchaRequestedListener(OnCaptchaRequested listener) {
         final boolean remainingListeners;
         synchronized (LISTENER_LOCK) {
@@ -3104,7 +3139,8 @@ public class XmppConnectionService extends Service {
                 && this.mOnUpdateBlocklist.isEmpty()
                 && this.mOnShowErrorToasts.isEmpty()
                 && this.onJingleRtpConnectionUpdate.isEmpty()
-                && this.mOnKeyStatusUpdated.isEmpty());
+                && this.mOnKeyStatusUpdated.isEmpty()
+                && this.mOnStoriesUpdates.isEmpty());
     }
 
     private void switchToForeground() {
@@ -3768,6 +3804,12 @@ public class XmppConnectionService extends Service {
         }
     }
 
+    public void updateStoriesUi() {
+        for (final OnStoriesUpdate listener : threadSafeList(this.mOnStoriesUpdates)) {
+            listener.onStoriesUpdate();
+        }
+    }
+
     public void updateRosterUi() {
         for (OnRosterUpdate listener : threadSafeList(this.mOnRosterUpdates)) {
             listener.onRosterUpdate();
@@ -4408,6 +4450,10 @@ public class XmppConnectionService extends Service {
         void onAccountUpdate();
     }
 
+    public interface OnStoriesUpdate {
+        void onStoriesUpdate();
+    }
+
     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 549dce5..645ea00 100644
--- a/src/main/java/tel/xmpp/jabjab/ui/BlogActivity.java
+++ b/src/main/java/tel/xmpp/jabjab/ui/BlogActivity.java
@@ -138,6 +138,11 @@ public class BlogActivity extends XmppActivity
         super.onResume();
         Log.d(Config.LOGTAG, "BLOG onResume: onlineAccounts=" + onlineAccounts.size()
                 + " svc=" + (xmppConnectionService != null));
+        // Mark blog as read — clears the unread badge on this tab.
+        getSharedPreferences(tel.xmpp.jabjab.xmpp.manager.BlogManager.BADGE_PREFS, MODE_PRIVATE)
+                .edit()
+                .putLong("last_read_ts", System.currentTimeMillis())
+                .apply();
         updateDraftBanner();
         if (!onlineAccounts.isEmpty()) loadPosts();
         BottomNavHelper.updateBadges(bottomNav, xmppConnectionService);
diff --git a/src/main/java/tel/xmpp/jabjab/ui/BottomNavHelper.java b/src/main/java/tel/xmpp/jabjab/ui/BottomNavHelper.java
index 4c838d9..c908f74 100644
--- a/src/main/java/tel/xmpp/jabjab/ui/BottomNavHelper.java
+++ b/src/main/java/tel/xmpp/jabjab/ui/BottomNavHelper.java
@@ -1,7 +1,9 @@
 package tel.xmpp.jabjab.ui;
 
 import android.app.Activity;
+import android.content.Context;
 import android.content.Intent;
+import android.content.SharedPreferences;
 import android.util.Log;
 import com.google.android.material.bottomnavigation.BottomNavigationView;
 import com.google.android.material.badge.BadgeDrawable;
@@ -9,6 +11,8 @@ import tel.xmpp.jabjab.Config;
 import tel.xmpp.jabjab.R;
 import tel.xmpp.jabjab.entities.Conversation;
 import tel.xmpp.jabjab.services.XmppConnectionService;
+import tel.xmpp.jabjab.xmpp.manager.BlogManager;
+import tel.xmpp.jabjab.xmpp.manager.StoriesManager;
 
 /**
  * Wires the persistent bottom nav (Chats / Channels / Stories / Blog) that's present
@@ -65,7 +69,8 @@ public final class BottomNavHelper {
     }
 
     /**
-     * Updates unread-count badges on the Chats and Channels nav items.
+     * Updates unread-count badges on the Chats and Channels nav items, and dot badges on
+     * Stories and Blog when new content has arrived since the user last opened those tabs.
      *
      * Must be called from the main thread. Safe to call at any time — it reads the
      * conversation list from the service and applies or removes badges atomically.
@@ -79,6 +84,8 @@ public final class BottomNavHelper {
             Log.d(Config.LOGTAG, "BottomNavHelper.updateBadges: service null, clearing badges");
             removeBadge(nav, R.id.bottom_chats);
             removeBadge(nav, R.id.bottom_channels);
+            removeBadge(nav, R.id.bottom_stories);
+            removeBadge(nav, R.id.bottom_blog);
             return;
         }
 
@@ -100,6 +107,31 @@ public final class BottomNavHelper {
 
         applyBadge(nav, R.id.bottom_chats, chatUnread);
         applyBadge(nav, R.id.bottom_channels, channelUnread);
+
+        // Stories dot badge: show if a push arrived after the user last read stories
+        final Context ctx = service.getApplicationContext();
+        applyDotBadge(nav, R.id.bottom_stories,
+                hasUnread(ctx, StoriesManager.BADGE_PREFS));
+        applyDotBadge(nav, R.id.bottom_blog,
+                hasUnread(ctx, BlogManager.BADGE_PREFS));
+    }
+
+    /** Returns true if badge_ts > last_read_ts in the given SharedPreferences file. */
+    private static boolean hasUnread(final Context ctx, final String prefsName) {
+        final SharedPreferences prefs = ctx.getSharedPreferences(prefsName, Context.MODE_PRIVATE);
+        final long badgeTs = prefs.getLong("badge_ts", 0L);
+        final long lastReadTs = prefs.getLong("last_read_ts", 0L);
+        return badgeTs > lastReadTs;
+    }
+
+    private static void applyDotBadge(final BottomNavigationView nav, final int itemId,
+            final boolean show) {
+        if (!show) {
+            removeBadge(nav, itemId);
+            return;
+        }
+        final BadgeDrawable badge = nav.getOrCreateBadge(itemId);
+        badge.clearNumber(); // dot mode — no count shown
     }
 
     private static void applyBadge(final BottomNavigationView nav, final int itemId, final int count) {
diff --git a/src/main/java/tel/xmpp/jabjab/ui/GifSearchActivity.java b/src/main/java/tel/xmpp/jabjab/ui/GifSearchActivity.java
index 45c40cf..61c544a 100644
--- a/src/main/java/tel/xmpp/jabjab/ui/GifSearchActivity.java
+++ b/src/main/java/tel/xmpp/jabjab/ui/GifSearchActivity.java
@@ -331,6 +331,12 @@ public class GifSearchActivity extends BaseActivity {
     @Override
     protected void onDestroy() {
         super.onDestroy();
+        // Cancel any pending debounced search before shutting the executor down;
+        // without this the postDelayed runnable fires after shutdown and throws RejectedExecutionException.
+        if (pendingSearch != null) {
+            recyclerView.removeCallbacks(pendingSearch);
+            pendingSearch = null;
+        }
         executor.shutdownNow();
     }
 
diff --git a/src/main/java/tel/xmpp/jabjab/ui/ImportBackupActivity.java b/src/main/java/tel/xmpp/jabjab/ui/ImportBackupActivity.java
index 5f01952..7b379e8 100644
--- a/src/main/java/tel/xmpp/jabjab/ui/ImportBackupActivity.java
+++ b/src/main/java/tel/xmpp/jabjab/ui/ImportBackupActivity.java
@@ -56,6 +56,7 @@ public class ImportBackupActivity extends ActionBarActivity
     private LiveData<Boolean> inProgressImport;
     private Uri currentRestoreDialog;
     private UUID currentWorkRequest;
+    private boolean pendingRestoreSuccess = false;
 
     private final ActivityResultLauncher<String[]> requestPermissions =
             registerForActivityResult(
@@ -130,6 +131,14 @@ public class ImportBackupActivity extends ActionBarActivity
     }
 
     @Override
+    public void onResume() {
+        super.onResume();
+        if (pendingRestoreSuccess) {
+            pendingRestoreSuccess = false;
+            onBackupRestored();
+        }
+    }
+
     public void onStart() {
         super.onStart();
 
@@ -333,6 +342,17 @@ public class ImportBackupActivity extends ActionBarActivity
         this.currentWorkRequest = id;
         monitorWorkRequest(id);
 
+        // Take a persistable URI permission so the WorkManager background thread can open the file.
+        // Without this, the content:// grant only lives for the Activity's lifetime and the worker
+        // gets a SecurityException.
+        try {
+            getContentResolver()
+                    .takePersistableUriPermission(
+                            backupFile.getUri(), Intent.FLAG_GRANT_READ_URI_PERMISSION);
+        } catch (final SecurityException e) {
+            Log.d(Config.LOGTAG, "could not take persistable uri permission for backup", e);
+        }
+
         final var workManager = WorkManager.getInstance(this);
         workManager.enqueue(importBackupWorkRequest);
     }
@@ -351,6 +371,7 @@ public class ImportBackupActivity extends ActionBarActivity
                         this.currentWorkRequest = null;
                     }
                     if (state == WorkInfo.State.FAILED) {
+                        releaseBackupUriPermission();
                         final var data = workInfo.getOutputData();
                         final var reason =
                                 ImportBackupWorker.Reason.valueOfOrGeneric(
@@ -361,6 +382,13 @@ public class ImportBackupActivity extends ActionBarActivity
                             default -> onBackupRestoreFailed();
                         }
                     } else if (state == WorkInfo.State.SUCCEEDED) {
+                        releaseBackupUriPermission();
+                        // Don't call startActivity from a background observer — Android 10+
+                        // blocks background activity launches silently, causing the wizard to
+                        // reappear. Set a flag and navigate in onResume instead.
+                        pendingRestoreSuccess = true;
+                        if (!getLifecycle().getCurrentState().isAtLeast(
+                                androidx.lifecycle.Lifecycle.State.RESUMED)) return;
                         onBackupRestored();
                     }
                 });
@@ -381,7 +409,26 @@ public class ImportBackupActivity extends ActionBarActivity
                 .show();
     }
 
+    private void releaseBackupUriPermission() {
+        if (this.currentRestoreDialog == null) return;
+        try {
+            getContentResolver()
+                    .releasePersistableUriPermission(
+                            this.currentRestoreDialog, Intent.FLAG_GRANT_READ_URI_PERMISSION);
+        } catch (final SecurityException e) {
+            Log.d(Config.LOGTAG, "could not release persistable uri permission for backup", e);
+        }
+    }
+
     private void onBackupRestored() {
+        // Mark wizard as complete so ConversationActivity doesn't redirect to it.
+        // The backup restore created all accounts in the DB; the wizard flag just wasn't set.
+        getSharedPreferences(
+                        tel.xmpp.jabjab.ui.wizard.SetupWizardActivity.PREFS_SETUP, MODE_PRIVATE)
+                .edit()
+                .putBoolean(
+                        tel.xmpp.jabjab.ui.wizard.SetupWizardActivity.KEY_WIZARD_COMPLETE, true)
+                .commit();
         final var intent = new Intent(this, ConversationActivity.class);
         intent.addFlags(
                 Intent.FLAG_ACTIVITY_CLEAR_TOP
diff --git a/src/main/java/tel/xmpp/jabjab/ui/StoriesActivity.java b/src/main/java/tel/xmpp/jabjab/ui/StoriesActivity.java
index 2172af1..9e37911 100644
--- a/src/main/java/tel/xmpp/jabjab/ui/StoriesActivity.java
+++ b/src/main/java/tel/xmpp/jabjab/ui/StoriesActivity.java
@@ -37,19 +37,23 @@ import tel.xmpp.jabjab.Config;
 import tel.xmpp.jabjab.R;
 import tel.xmpp.jabjab.entities.Account;
 import tel.xmpp.jabjab.entities.Contact;
+import tel.xmpp.jabjab.persistance.DatabaseBackend;
 import tel.xmpp.jabjab.ui.util.AvatarWorkerTask;
 import tel.xmpp.jabjab.ui.util.LinkPreviewFetcher;
 import tel.xmpp.jabjab.xmpp.manager.StoriesManager;
 import im.conversations.android.xmpp.model.stories.Story;
 import java.util.ArrayList;
 import java.util.Collections;
+import java.util.LinkedHashMap;
 import java.util.List;
+import java.util.Map;
 import java.util.concurrent.ExecutorService;
 import java.util.concurrent.Executors;
 
 
 public class StoriesActivity extends XmppActivity
-        implements tel.xmpp.jabjab.services.XmppConnectionService.OnAccountUpdate {
+        implements tel.xmpp.jabjab.services.XmppConnectionService.OnAccountUpdate,
+                   tel.xmpp.jabjab.services.XmppConnectionService.OnStoriesUpdate {
 
     public static final String EXTRA_ACCOUNT = "account";
 
@@ -69,12 +73,11 @@ public class StoriesActivity extends XmppActivity
 
     private final List<StoryEntry> entries = new ArrayList<>();
     private final List<StoryEntry> filteredEntries = new ArrayList<>();
-    private final List<StoryEntry> contactEntries = Collections.synchronizedList(new ArrayList<>());
-    private final List<StoryEntry> ownEntries = Collections.synchronizedList(new ArrayList<>());
-    private int loadGeneration = 0;
+    private final List<StoryEntry> contactEntries = new ArrayList<>();
+    private final List<StoryEntry> ownEntries = new ArrayList<>();
+    private int loadGeneration = 0; // used to suppress stale swipe-refresh callbacks
 
     private final android.os.Handler uiHandler = new android.os.Handler(android.os.Looper.getMainLooper());
-    private Runnable pendingRebuild = null;
     // Refreshes relative timestamps ("2 minutes ago") every minute while the activity is visible
     private final Runnable timeRefreshRunnable = new Runnable() {
         @Override public void run() {
@@ -155,7 +158,15 @@ public class StoriesActivity extends XmppActivity
     protected void onResume() {
         super.onResume();
         uiHandler.post(timeRefreshRunnable);
+        // Mark stories as read — clears the unread badge on this tab.
+        getSharedPreferences(tel.xmpp.jabjab.xmpp.manager.StoriesManager.BADGE_PREFS, MODE_PRIVATE)
+                .edit()
+                .putLong("last_read_ts", System.currentTimeMillis())
+                .apply();
         BottomNavHelper.updateBadges(bottomNav, xmppConnectionService);
+        if (xmppConnectionService != null) {
+            xmppConnectionService.setOnStoriesUpdateListener(this);
+        }
         // Re-load if the backend was already connected when we come back to this activity
         // (e.g. returning from blog via bottom nav). onBackendConnected fires on first bind
         // but the connection is sometimes not ready yet; onResume catches the return case.
@@ -170,6 +181,23 @@ public class StoriesActivity extends XmppActivity
     public void onPause() {
         super.onPause();
         uiHandler.removeCallbacks(timeRefreshRunnable);
+        if (xmppConnectionService != null) {
+            xmppConnectionService.removeOnStoriesUpdateListener(this);
+        }
+    }
+
+    @Override
+    public void onStoriesUpdate() {
+        // The DB cache was updated (push event or background fetch completed) — re-render from cache.
+        // Also refresh last_read_ts so background fetches don't leave a stale unread dot while
+        // the user is actively viewing this screen.
+        Log.d(Config.LOGTAG, "STORIES onStoriesUpdate: re-rendering from DB cache");
+        getSharedPreferences(tel.xmpp.jabjab.xmpp.manager.StoriesManager.BADGE_PREFS, MODE_PRIVATE)
+                .edit()
+                .putLong("last_read_ts", System.currentTimeMillis())
+                .apply();
+        loadFromCache();
+        BottomNavHelper.updateBadges(bottomNav, xmppConnectionService);
     }
 
     @Override
@@ -206,188 +234,132 @@ public class StoriesActivity extends XmppActivity
         }
         Log.d(Config.LOGTAG, "STORIES onBackendConnected: onlineAccounts=" + onlineAccounts.size());
         updateComposeFabVisibility();
+        xmppConnectionService.setOnStoriesUpdateListener(this);
         loadStories();
     }
 
     private void loadStories() {
         final Account primary = primaryOnline();
+
+        // ── Phase 1: render from DB cache immediately (no network, no blank screen) ──
+        loadFromCache();
+
         if (primary == null || primary.getXmppConnection() == null) {
-            // Not online — wait for onAccountUpdate to fire rather than polling.
-            Log.d(Config.LOGTAG, "STORIES loadStories: not connected, waiting for account update");
-            runOnUiThread(() -> {
-                if (swipeRefresh != null) swipeRefresh.setRefreshing(false);
-                updateEmptyView();
-            });
+            Log.d(Config.LOGTAG, "STORIES loadStories: not online, showing DB cache only");
+            if (swipeRefresh != null) swipeRefresh.setRefreshing(false);
             return;
         }
 
+        // ── Phase 2: background network refresh ──────────────────────────────────────
+        // AccountStateProcessor fires fetchAndCache() for all contacts when the account
+        // comes online, so this path only runs on explicit swipe-to-refresh or when the
+        // activity opens while already online (service prefetch may be seconds ahead).
         final int gen = ++loadGeneration;
-        ownEntries.clear();
-        contactEntries.clear();
-
-        // Clear the UI immediately so stale entries from a previous load don't linger.
-        runOnUiThread(() -> {
-            entries.clear();
-            filteredEntries.clear();
-            adapter.notifyDataSetChanged();
-            updateEmptyView();
-        });
-
-        final var primaryMgr = primary.getXmppConnection().getManager(StoriesManager.class);
+        Log.d(Config.LOGTAG, "STORIES loadStories gen=" + gen + ": queuing network refresh");
 
-        // Each own account's stories appear as a SEPARATE entry — no merging.
-        // Disabled (offline) own accounts are fetched via the primary connection (read-only).
         final List<Account> allOwnAccounts = tel.xmpp.jabjab.ui.util.XmppTelAccounts.get(xmppConnectionService);
-        final List<Account> ownOnline = new ArrayList<>();
-        for (final Account a : allOwnAccounts) {
-            if (a.isOnlineAndConnected()) ownOnline.add(a);
-        }
-        final List<Account> ownOffline = new ArrayList<>(allOwnAccounts);
-        ownOffline.removeAll(ownOnline);
-
         final java.util.Set<String> ownJids = new java.util.HashSet<>();
         for (final Account a : allOwnAccounts) ownJids.add(a.getJid().asBareJid().toString());
 
-        // Combine contacts across all online accounts, dedup by bare JID, filter to subscribed (TO).
         final java.util.LinkedHashMap<String, Contact> contactMap = new java.util.LinkedHashMap<>();
         for (final Account a : onlineAccounts) {
             for (final Contact c : a.getRoster().getContacts()) {
                 final String cjid = c.getAddress().asBareJid().toString();
-                // Fetch stories from any TO-subscribed contact, like Monocles — no domain filter
                 if (c.getOption(Contact.Options.TO) && !ownJids.contains(cjid)) {
                     contactMap.putIfAbsent(cjid, c);
                 }
             }
         }
-        final List<Contact> subscribed = new ArrayList<>(contactMap.values());
-        Log.d(Config.LOGTAG, "STORIES loadStories gen=" + gen + ": primary=" + primary.getJid()
-                + " ownOnline=" + ownOnline.size() + " ownOffline=" + ownOffline.size()
-                + " subscribed=" + subscribed.size());
-
-        for (final Account ownAccount : ownOnline) {
-            final var ownMgr = ownAccount.getXmppConnection().getManager(StoriesManager.class);
-            final tel.xmpp.jabjab.xmpp.Jid ownJid = ownAccount.getJid().asBareJid();
-            Log.d(Config.LOGTAG, "STORIES fetching own stories for " + ownJid);
-            Futures.addCallback(
-                    ownMgr.fetchStoriesWithIds(ownJid),
-                    new FutureCallback<java.util.LinkedHashMap<String, Story>>() {
-                        @Override
-                        public void onSuccess(final java.util.LinkedHashMap<String, Story> map) {
-                            if (gen != loadGeneration) return;
-                            Log.d(Config.LOGTAG, "STORIES own " + ownJid + " fetch success: " + map.size() + " stories");
-                            if (!map.isEmpty()) {
-                                final List<String> ownerJids = new ArrayList<>();
-                                for (int i = 0; i < map.size(); i++) ownerJids.add(ownJid.toString());
-                                ownEntries.add(new StoryEntry(
-                                        "@" + ownAccount.getJid().getLocal(), null,
-                                        new ArrayList<>(map.keySet()),
-                                        new ArrayList<>(map.values()),
-                                        ownerJids, ownAccount));
-                            }
-                            oneFetchDone(gen);
-                        }
-                        @Override
-                        public void onFailure(final Throwable t) {
-                            if (gen != loadGeneration) return;
-                            Log.d(Config.LOGTAG, "STORIES own " + ownJid + " fetch failed: " + t.getMessage());
-                            oneFetchDone(gen);
-                        }
-                    },
-                    MoreExecutors.directExecutor());
-        }
 
-        // Fetch disabled own accounts via primary — stories are visible but can't be deleted
-        // (ownerAccount=null signals StoryViewActivity not to show the delete button).
-        for (final Account ownAccount : ownOffline) {
-            final tel.xmpp.jabjab.xmpp.Jid ownJid = ownAccount.getJid().asBareJid();
-            Log.d(Config.LOGTAG, "STORIES fetching offline-own stories for " + ownJid);
-            Futures.addCallback(
-                    primaryMgr.fetchStoriesWithIds(ownJid),
-                    new FutureCallback<java.util.LinkedHashMap<String, Story>>() {
-                        @Override
-                        public void onSuccess(final java.util.LinkedHashMap<String, Story> map) {
-                            if (gen != loadGeneration) return;
-                            Log.d(Config.LOGTAG, "STORIES offline-own " + ownJid + " fetch success: " + map.size() + " stories");
-                            if (!map.isEmpty()) {
-                                final List<String> ownerJids = new ArrayList<>();
-                                for (int i = 0; i < map.size(); i++) ownerJids.add(ownJid.toString());
-                                ownEntries.add(new StoryEntry(
-                                        "@" + ownAccount.getJid().getLocal(), null,
-                                        new ArrayList<>(map.keySet()),
-                                        new ArrayList<>(map.values()),
-                                        ownerJids, null));
-                            }
-                            oneFetchDone(gen);
-                        }
-                        @Override
-                        public void onFailure(final Throwable t) {
-                            if (gen != loadGeneration) return;
-                            final String msg = t.getMessage();
-                            if (msg == null || !msg.contains("item-not-found")) {
-                                Log.d(Config.LOGTAG, "STORIES offline-own " + ownJid + " fetch failed: " + msg);
-                            }
-                            oneFetchDone(gen);
-                        }
-                    },
-                    MoreExecutors.directExecutor());
+        // fetchAndCache writes to DB and calls updateStoriesUi() which triggers
+        // onStoriesUpdate() → loadFromCache() for an incremental UI refresh.
+        final var primaryMgr = primary.getXmppConnection().getManager(StoriesManager.class);
+        for (final Account a : allOwnAccounts) {
+            final var mgr = a.isOnlineAndConnected()
+                    ? a.getXmppConnection().getManager(StoriesManager.class)
+                    : primaryMgr;
+            mgr.fetchAndCache(a.getJid().asBareJid());
         }
-
-        // Safety net: if no fetches are pending at all, trigger a rebuild to show empty state
-        if (ownOnline.isEmpty() && ownOffline.isEmpty() && subscribed.isEmpty()) {
-            oneFetchDone(gen);
+        for (final Contact contact : contactMap.values()) {
+            primaryMgr.fetchAndCache(contact.getAddress());
         }
 
-        for (final Contact contact : subscribed) {
-            Log.d(Config.LOGTAG, "STORIES fetching stories for contact " + contact.getAddress());
-            Futures.addCallback(
-                    primaryMgr.fetchStoriesWithIds(contact.getAddress()),
-                    new FutureCallback<java.util.LinkedHashMap<String, Story>>() {
-                        @Override
-                        public void onSuccess(final java.util.LinkedHashMap<String, Story> map) {
-                            if (gen != loadGeneration) return;
-                            Log.d(Config.LOGTAG, "STORIES contact " + contact.getAddress()
-                                    + " fetch success: " + map.size() + " non-expired stories");
-                            if (!map.isEmpty()) {
-                                contactEntries.add(new StoryEntry(
-                                        "@" + contact.getAddress().getLocal(), contact,
-                                        new ArrayList<>(map.keySet()),
-                                        new ArrayList<>(map.values())));
-                            }
-                            oneFetchDone(gen);
-                        }
-                        @Override
-                        public void onFailure(final Throwable t) {
-                            if (gen != loadGeneration) return;
-                            final String msg = t.getMessage();
-                            if (msg == null || !msg.contains("item-not-found")) {
-                                Log.d(Config.LOGTAG, "STORIES contact " + contact.getAddress()
-                                        + " fetch failed: " + t.getMessage());
-                            }
-                            oneFetchDone(gen);
-                        }
-                    },
-                    MoreExecutors.directExecutor());
-        }
+        // fetchAndCache calls updateStoriesUi() on completion; swipeRefresh spinner
+        // stays visible until onStoriesUpdate fires (which calls loadFromCache and stops it).
     }
 
-    // Called after each individual fetch completes (success or failure).
-    // Debounced: rapid successive completions (e.g. 50 contacts all returning at once)
-    // are coalesced into a single UI rebuild 50ms after the last one fires.
-    private void oneFetchDone(final int gen) {
-        if (gen != loadGeneration) return;
-        if (pendingRebuild != null) uiHandler.removeCallbacks(pendingRebuild);
-        pendingRebuild = () -> {
-            if (gen != loadGeneration) return;
+    /**
+     * Renders story entries from the local DB cache. Fast — no network I/O.
+     * Called on every screen open and whenever onStoriesUpdate fires.
+     */
+    private void loadFromCache() {
+        if (xmppConnectionService == null) return;
+        final List<Account> allOwnAccounts = tel.xmpp.jabjab.ui.util.XmppTelAccounts.get(xmppConnectionService);
+        if (allOwnAccounts.isEmpty()) return;
+
+        final DatabaseBackend db = DatabaseBackend.getInstance(this);
+        final List<StoryEntry> newOwn = new ArrayList<>();
+        final List<StoryEntry> newContacts = new ArrayList<>();
+        final java.util.Set<String> ownJids = new java.util.HashSet<>();
+        for (final Account a : allOwnAccounts) 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<>();
+
+        for (final Account a : allOwnAccounts) {
+            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);
+            if (ownStories != null && !ownStories.isEmpty()) {
+                final List<String> ownerJids = new ArrayList<>();
+                for (int i = 0; i < ownStories.size(); i++) ownerJids.add(accountJid);
+                newOwn.add(new StoryEntry(
+                        "@" + a.getJid().getLocal(), null,
+                        new ArrayList<>(ownStories.keySet()),
+                        new ArrayList<>(ownStories.values()),
+                        ownerJids, a.isOnlineAndConnected() ? a : null));
+            }
+
+            // Contact stories: build roster lookup for display names + Contact objects
+            final java.util.Map<String, Contact> rosterByJid = new java.util.HashMap<>();
+            if (a.isOnlineAndConnected()) {
+                for (final Contact c : a.getRoster().getContacts()) {
+                    rosterByJid.put(c.getAddress().asBareJid().toString(), c);
+                }
+            }
+            for (final Map.Entry<String, LinkedHashMap<String, Story>> e : cache.entrySet()) {
+                final String contactJid = e.getKey();
+                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();
+                if (stories.isEmpty()) continue;
+                final Contact contact = rosterByJid.get(contactJid);
+                final String name = contact != null ? contact.getDisplayName() : contactJid;
+                newContacts.add(new StoryEntry(
+                        name, contact,
+                        new ArrayList<>(stories.keySet()),
+                        new ArrayList<>(stories.values())));
+            }
+        }
+
+        Log.d(Config.LOGTAG, "STORIES loadFromCache: own=" + newOwn.size()
+                + " contacts=" + newContacts.size());
+
+        runOnUiThread(() -> {
+            ownEntries.clear();
+            ownEntries.addAll(newOwn);
+            contactEntries.clear();
+            contactEntries.addAll(newContacts);
             entries.clear();
             entries.addAll(ownEntries);
             entries.addAll(contactEntries);
-            Log.d(Config.LOGTAG, "STORIES rebuild gen=" + gen + ": entries=" + entries.size());
             applyFilter();
             if (swipeRefresh != null) swipeRefresh.setRefreshing(false);
-        };
-        uiHandler.postDelayed(pendingRebuild, 50);
+        });
     }
 
+    // Called after each individual fetch completes (success or failure).
     private void applyFilter() {
         final String query = searchInput != null
                 ? searchInput.getText().toString().trim().toLowerCase() : "";
diff --git a/src/main/java/tel/xmpp/jabjab/ui/StoryViewActivity.java b/src/main/java/tel/xmpp/jabjab/ui/StoryViewActivity.java
index d69caf1..9ebeee3 100644
--- a/src/main/java/tel/xmpp/jabjab/ui/StoryViewActivity.java
+++ b/src/main/java/tel/xmpp/jabjab/ui/StoryViewActivity.java
@@ -35,8 +35,14 @@ import tel.xmpp.jabjab.Config;
 import tel.xmpp.jabjab.R;
 import tel.xmpp.jabjab.entities.Account;
 import tel.xmpp.jabjab.xmpp.manager.StoriesManager;
+import java.io.File;
+import java.io.FileOutputStream;
 import java.io.IOException;
+import java.io.OutputStream;
 import java.nio.ByteBuffer;
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
 import java.util.concurrent.ExecutorService;
 import java.util.concurrent.Executors;
 import okhttp3.OkHttpClient;
@@ -69,6 +75,10 @@ public class StoryViewActivity extends XmppActivity {
     private int currentIndex = 0;
     private boolean isOwnStory = false;
     private boolean isPaused = false;
+    // True while the user is holding their finger down to pause; cleared on finger up.
+    private boolean holdPaused = false;
+    // True when the currently displayed story is a video (so togglePause can pause/resume it).
+    private boolean currentIsVideo = false;
     private Account viewerAccount;
 
     private ImageView imageView;
@@ -147,18 +157,29 @@ public class StoryViewActivity extends XmppActivity {
 
         buildProgressStrip();
 
-        // Left 33% = prev, right = next; centre tap toggles pause. Horizontal swipe
-        // also advances/goes back, regardless of where on the screen it starts.
+        // Left 33% = prev, right 33% = next; hold anywhere pauses until finger lifts.
+        // Horizontal fling also advances/goes back regardless of start position.
         final GestureDetector gd = new GestureDetector(this,
                 new GestureDetector.SimpleOnGestureListener() {
                     @Override
                     public boolean onSingleTapUp(final MotionEvent e) {
+                        // Ignore taps if we're in a hold-pause; the release will resume.
+                        if (holdPaused) return true;
                         final int w = findViewById(R.id.story_root).getWidth();
                         if (e.getX() < w * 0.33f) showStory(currentIndex - 1);
                         else                        showStory(currentIndex + 1);
                         return true;
                     }
 
+                    @Override
+                    public void onLongPress(final MotionEvent e) {
+                        // Hold anywhere on the screen to pause; release resumes.
+                        if (!isPaused) {
+                            holdPaused = true;
+                            togglePause();
+                        }
+                    }
+
                     @Override
                     public boolean onFling(
                             final MotionEvent e1, final MotionEvent e2,
@@ -179,9 +200,15 @@ public class StoryViewActivity extends XmppActivity {
         // Must claim the whole gesture (return true unconditionally) — if ACTION_DOWN
         // isn't claimed here, the dispatcher won't keep routing the following MOVE/UP
         // events to this listener, and GestureDetector never sees enough of the
-        // sequence to recognize a fling.
+        // sequence to recognize a fling or long press.
         findViewById(R.id.story_root).setOnTouchListener((v, e) -> {
             gd.onTouchEvent(e);
+            // On finger lift: if we paused due to a hold, resume automatically.
+            if ((e.getAction() == MotionEvent.ACTION_UP
+                    || e.getAction() == MotionEvent.ACTION_CANCEL) && holdPaused) {
+                holdPaused = false;
+                if (isPaused) togglePause();
+            }
             return true;
         });
 
@@ -272,12 +299,12 @@ public class StoryViewActivity extends XmppActivity {
         loading.setIndeterminate(true);
 
         final boolean isVideo = mime != null && mime.startsWith("video/");
+        currentIsVideo = isVideo;
         final boolean isGif   = "image/gif".equals(mime);
         if (isVideo) {
             loading.setVisibility(View.VISIBLE);
             imageView.setVisibility(View.GONE);
             videoView.setVisibility(View.VISIBLE);
-            videoView.setVideoURI(Uri.parse(url));
             // Drive the progress bar off the video's own length (capped at the upload
             // limit) instead of the fixed image timer — a longer video must not get cut
             // off by a 5s timer, and a shorter one shouldn't sit on a finished bar.
@@ -292,6 +319,7 @@ public class StoryViewActivity extends XmppActivity {
                         : STORY_DURATION_MS;
                 startProgressAndAdvance(duration, duration);
             });
+            loadVideo(url, index);
         } else if (isGif) {
             // GIFs: animate and don't auto-advance — user taps to move forward.
             loading.setVisibility(View.VISIBLE);
@@ -343,6 +371,73 @@ public class StoryViewActivity extends XmppActivity {
         }
     }
 
+    /** Returns a stable per-URL file in the video cache dir; creates dir if needed. */
+    private File getVideoCacheFile(final String url) {
+        final File dir = new File(getCacheDir(), "story_video_cache");
+        //noinspection ResultOfMethodCallIgnored
+        dir.mkdirs();
+        // SHA-256 of the URL → deterministic filename, no collisions, survives restarts.
+        String hash;
+        try {
+            final byte[] digest = MessageDigest.getInstance("SHA-256")
+                    .digest(url.getBytes(StandardCharsets.UTF_8));
+            final StringBuilder sb = new StringBuilder(64);
+            for (final byte b : digest) sb.append(String.format("%02x", b));
+            hash = sb.toString();
+        } catch (final NoSuchAlgorithmException e) {
+            hash = Integer.toHexString(url.hashCode());
+        }
+        return new File(dir, hash + ".video");
+    }
+
+    /**
+     * Downloads a story video to the local disk cache if not already present, then
+     * hands the cached file path to VideoView. On a cache hit (file already exists)
+     * this is instant — no network I/O at all.
+     */
+    private void loadVideo(final String url, final int requestIndex) {
+        if (executor.isShutdown()) return;
+        executor.submit(() -> {
+            final File cacheFile = getVideoCacheFile(url);
+            if (!cacheFile.exists() || cacheFile.length() == 0) {
+                // Cache miss: download via OkHttp (same client used for images — disk-cached at
+                // the HTTP layer too, but VideoView can't use OkHttp's response body directly).
+                Log.d(Config.LOGTAG, "STORIES video cache miss, downloading: " + url);
+                try {
+                    final byte[] bytes = downloadBytes(url);
+                    if (bytes != null && bytes.length > 0) {
+                        final File tmp = new File(cacheFile.getParent(), cacheFile.getName() + ".tmp");
+                        try (final OutputStream os = new FileOutputStream(tmp)) {
+                            os.write(bytes);
+                        }
+                        // Atomic rename so a partial download is never left as the cache file.
+                        if (!tmp.renameTo(cacheFile)) {
+                            Log.d(Config.LOGTAG, "STORIES video cache rename failed");
+                            //noinspection ResultOfMethodCallIgnored
+                            tmp.delete();
+                        }
+                    }
+                } catch (final IOException e) {
+                    Log.d(Config.LOGTAG, "STORIES video download failed: " + url, e);
+                    runOnUiThread(() -> {
+                        if (isDestroyed() || requestIndex != currentIndex) return;
+                        loading.setVisibility(View.GONE);
+                        startProgressAndAdvance(STORY_DURATION_MS, STORY_DURATION_MS);
+                    });
+                    return;
+                }
+            } else {
+                Log.d(Config.LOGTAG, "STORIES video cache hit: " + cacheFile.getName());
+            }
+            if (!cacheFile.exists()) return;
+            final String path = cacheFile.getAbsolutePath();
+            runOnUiThread(() -> {
+                if (isDestroyed() || requestIndex != currentIndex) return;
+                videoView.setVideoPath(path);
+            });
+        });
+    }
+
     private void loadImage(final String url, final int requestIndex) {
         if (executor.isShutdown()) return;
         executor.submit(() -> {
@@ -445,12 +540,14 @@ public class StoryViewActivity extends XmppActivity {
             btnPause.setImageResource(R.drawable.ic_pause_24dp);
             final long elapsed = System.currentTimeMillis() - pausedAt;
             remainingMs = Math.max(0, remainingMs - elapsed);
+            if (currentIsVideo) videoView.start();
             startProgressAndAdvance(currentTotalMs, remainingMs);
         } else {
             // Pause
             isPaused = true;
             pausedAt = System.currentTimeMillis();
             btnPause.setImageResource(R.drawable.ic_play_arrow_24dp);
+            if (videoView.isPlaying()) videoView.pause();
             if (currentAnimator != null) currentAnimator.pause();
             if (advanceRunnable != null) handler.removeCallbacks(advanceRunnable);
         }
diff --git a/src/main/java/tel/xmpp/jabjab/ui/adapter/MessageAdapter.java b/src/main/java/tel/xmpp/jabjab/ui/adapter/MessageAdapter.java
index 5e08890..4818c36 100644
--- a/src/main/java/tel/xmpp/jabjab/ui/adapter/MessageAdapter.java
+++ b/src/main/java/tel/xmpp/jabjab/ui/adapter/MessageAdapter.java
@@ -3019,6 +3019,7 @@ public class MessageAdapter extends ArrayAdapter<Message> {
         final android.view.ViewGroup blockquoteContainer;
         final View stripe;
         final android.widget.TextView blockquote;
+        final MaterialButton locationButton;
         final android.widget.TextView time;
 
         ChannelCardMessageItemViewHolder(@NonNull View root) {
@@ -3030,16 +3031,21 @@ public class MessageAdapter extends ArrayAdapter<Message> {
             blockquoteContainer = root.findViewById(R.id.channel_card_blockquote_container);
             stripe             = root.findViewById(R.id.channel_card_stripe);
             blockquote         = root.findViewById(R.id.channel_card_blockquote);
+            locationButton     = root.findViewById(R.id.channel_card_location_button);
             time               = root.findViewById(R.id.channel_card_time);
         }
     }
 
+    // Matches a standalone geo: URI on its own line (with optional surrounding whitespace).
+    private static final java.util.regex.Pattern GEO_LINE =
+            java.util.regex.Pattern.compile("(?m)^[ \t]*(geo:[^\r\n]+)[ \t]*$");
+
     private View renderChannelCard(
             final Message message,
             final ChannelCardMessageItemViewHolder h) {
-        final String body = message.getBody();
+        final String rawBody = message.getBody();
         final tel.xmpp.jabjab.ui.util.ChannelMessageParser.ChannelCard card =
-                tel.xmpp.jabjab.ui.util.ChannelMessageParser.parse(body);
+                tel.xmpp.jabjab.ui.util.ChannelMessageParser.parse(rawBody);
 
         // Thumbnail — tag-guard prevents re-triggering loadImage on every notifyDataSetChanged
         if (card.imageUrl() != null) {
@@ -3069,14 +3075,43 @@ public class MessageAdapter extends ArrayAdapter<Message> {
             h.nick.setVisibility(View.GONE);
         }
 
-        // Title + body
+        // Extract geo: line from body (if any) and strip it so it doesn't appear as text.
+        String cardBodyText = card.body() != null ? card.body() : "";
+        org.osmdroid.util.GeoPoint geoPoint = null;
+        final java.util.regex.Matcher geoMatcher = GEO_LINE.matcher(cardBodyText);
+        if (geoMatcher.find()) {
+            final String geoUrl = geoMatcher.group(1);
+            try {
+                geoPoint = GeoHelper.parseGeoPoint(geoUrl);
+            } catch (final IllegalArgumentException ignored) {}
+            // Remove the geo: line (and any blank lines it leaves behind)
+            cardBodyText = geoMatcher.replaceAll("").trim();
+        }
+
+        // Title + body (geo: line already removed)
         final int accentColor = MaterialColors.getColor(
                 h.text, androidx.appcompat.R.attr.colorPrimary);
         final int dimColor = MaterialColors.getColor(
                 h.text, com.google.android.material.R.attr.colorOnSurfaceVariant);
         final int codeBgColor = MaterialColors.getColor(
                 h.text, com.google.android.material.R.attr.colorSurfaceVariant);
-        h.text.setText(tel.xmpp.jabjab.ui.util.Markdown.renderCard(card, accentColor, dimColor, codeBgColor));
+        final tel.xmpp.jabjab.ui.util.ChannelMessageParser.ChannelCard cardWithoutGeo =
+                new tel.xmpp.jabjab.ui.util.ChannelMessageParser.ChannelCard(
+                        card.imageUrl(), card.title(), cardBodyText, card.blockquote(), card.postedAt());
+        linkifyCardText(
+                h.text,
+                tel.xmpp.jabjab.ui.util.Markdown.renderCard(cardWithoutGeo, accentColor, dimColor, codeBgColor));
+
+        // Location button — shown instead of the raw geo: text
+        if (geoPoint != null) {
+            final org.osmdroid.util.GeoPoint finalGeoPoint = geoPoint;
+            h.locationButton.setVisibility(View.VISIBLE);
+            h.locationButton.setText(R.string.show_location);
+            h.locationButton.setOnClickListener(v ->
+                    activity.startActivity(GeoHelper.showLocationIntent(activity, finalGeoPoint)));
+        } else {
+            h.locationButton.setVisibility(View.GONE);
+        }
 
         // Blockquote — tinted background + accent stripe
         if (card.blockquote() != null && !card.blockquote().isEmpty()) {
@@ -3084,14 +3119,61 @@ public class MessageAdapter extends ArrayAdapter<Message> {
             h.blockquoteContainer.setVisibility(View.VISIBLE);
             h.blockquoteContainer.setBackgroundColor(bqBg);
             h.stripe.setBackgroundColor(accentColor);
-            h.blockquote.setText(card.blockquote());
+            linkifyCardText(h.blockquote, card.blockquote());
         } else {
             h.blockquoteContainer.setVisibility(View.GONE);
         }
 
-        // Timestamp
-        h.time.setText(UIHelper.readableTimeDifferenceFull(activity, message.getTimeSent()));
+        // Timestamp — prefer the original source's publish time (from the @<ISO-8601>
+        // line) over the XMPP delivery time, so the card reflects when it actually happened.
+        final long displayTime = card.postedAt() != null ? card.postedAt() : message.getTimeSent();
+        h.time.setText(UIHelper.readableChannelCardTime(activity, displayTime));
 
         return h.root;
     }
+
+    /**
+     * Linkify a channel card's text/blockquote.
+     * geo: lines are stripped before this is called, so no special-casing is needed here.
+     * If the last line of the text is a bare URL (a source/reference link), it is rendered
+     * smaller and dimmer so it reads as metadata rather than body content.
+     */
+    private void linkifyCardText(final TextView view, final CharSequence text) {
+        final SpannableStringBuilder spannable = new SpannableStringBuilder(text);
+        Linkify.addLinks(spannable);
+
+        // If the last line is a standalone URL, style it as a dim source link.
+        styleLastLineAsSourceUrl(spannable, view);
+
+        view.setText(spannable);
+        view.setMovementMethod(ClickableMovementMethod.getInstance());
+    }
+
+    /**
+     * Detects a standalone URL on the last line of the spannable and makes it
+     * smaller (80%) and dimmer (55% alpha) so it visually reads as a source reference.
+     */
+    private static void styleLastLineAsSourceUrl(
+            final SpannableStringBuilder spannable, final TextView view) {
+        final String str = spannable.toString();
+        final int lastNl = str.lastIndexOf('\n');
+        final int lineStart = lastNl + 1; // 0 if no newline
+        if (lineStart == 0) return;       // only one line — don't style (it IS the content)
+        final String lastLine = str.substring(lineStart).trim();
+        if (lastLine.isEmpty()) return;
+        if (!lastLine.startsWith("http://") && !lastLine.startsWith("https://")) return;
+        // The last line is a URL. Find its actual start/end in the spannable (skip leading whitespace).
+        final int urlStart = str.indexOf(lastLine, lineStart);
+        if (urlStart < 0) return;
+        final int urlEnd = urlStart + lastLine.length();
+        final int dimColor = MaterialColors.getColor(
+                view, com.google.android.material.R.attr.colorOnSurfaceVariant);
+        spannable.setSpan(
+                new RelativeSizeSpan(0.80f),
+                urlStart, urlEnd, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
+        spannable.setSpan(
+                new ForegroundColorSpan(
+                        (dimColor & 0x00FFFFFF) | 0x8C000000), // ~55% alpha
+                urlStart, urlEnd, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
+    }
 }
diff --git a/src/main/java/tel/xmpp/jabjab/ui/util/ChannelMessageParser.java b/src/main/java/tel/xmpp/jabjab/ui/util/ChannelMessageParser.java
index e03c82b..a2169e4 100644
--- a/src/main/java/tel/xmpp/jabjab/ui/util/ChannelMessageParser.java
+++ b/src/main/java/tel/xmpp/jabjab/ui/util/ChannelMessageParser.java
@@ -1,5 +1,7 @@
 package tel.xmpp.jabjab.ui.util;
 
+import java.time.Instant;
+import java.time.format.DateTimeParseException;
 import java.util.regex.Matcher;
 import java.util.regex.Pattern;
 
@@ -8,12 +10,17 @@ public class ChannelMessageParser {
     private static final Pattern IMAGE = Pattern.compile("^!\\[([^\\]]*)\\]\\(([^)]+)\\)");
     private static final Pattern HEADING = Pattern.compile("(?m)^#{1,5} (.+)$");
     private static final Pattern BLOCKQUOTE = Pattern.compile("(?m)^> (.+)$");
+    // Original-source publish time, e.g. "@2026-07-04T10:36:53+00:00" — lets a channel
+    // card show when the source actually posted it, instead of only the XMPP delivery time.
+    private static final Pattern POSTED_AT =
+            Pattern.compile("(?m)^@(\\d{4}-\\d{2}-\\d{2}T[\\d:.+\\-Z]+)$");
 
     public record ChannelCard(
             String imageUrl,    // null if no ![] line
             String title,       // null if no # heading
-            String body,        // remaining text after stripping image/heading/blockquotes
-            String blockquote   // last (or only) > block, null if none
+            String body,        // remaining text after stripping image/heading/blockquotes/date
+            String blockquote,  // last (or only) > block, null if none
+            Long postedAt       // epoch millis from the @<ISO-8601> line, null if none/unparseable
     ) {}
 
     /**
@@ -28,7 +35,7 @@ public class ChannelMessageParser {
     }
 
     public static ChannelCard parse(final String raw) {
-        if (raw == null) return new ChannelCard(null, null, "", null);
+        if (raw == null) return new ChannelCard(null, null, "", null, null);
 
         String rest = raw.trim();
 
@@ -50,6 +57,19 @@ public class ChannelMessageParser {
             rest = rest.trim();
         }
 
+        // Extract original-source publish date, if present
+        Long postedAt = null;
+        final Matcher dateMatcher = POSTED_AT.matcher(rest);
+        if (dateMatcher.find()) {
+            try {
+                postedAt = Instant.parse(dateMatcher.group(1)).toEpochMilli();
+            } catch (final DateTimeParseException ignored) {
+                // leave postedAt null — fall back to XMPP delivery time
+            }
+            rest = rest.substring(0, dateMatcher.start()) + rest.substring(dateMatcher.end());
+            rest = rest.trim();
+        }
+
         // Extract all blockquote lines (collect into single string)
         final StringBuilder bqBuilder = new StringBuilder();
         final Matcher bqMatcher = BLOCKQUOTE.matcher(rest);
@@ -63,6 +83,6 @@ public class ChannelMessageParser {
         rest = sb.toString().trim();
         final String blockquote = bqBuilder.length() > 0 ? bqBuilder.toString() : null;
 
-        return new ChannelCard(imageUrl, title, rest, blockquote);
+        return new ChannelCard(imageUrl, title, rest, blockquote, postedAt);
     }
 }
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 5ac5aac..723f5b1 100644
--- a/src/main/java/tel/xmpp/jabjab/ui/util/UpdateChecker.java
+++ b/src/main/java/tel/xmpp/jabjab/ui/util/UpdateChecker.java
@@ -130,6 +130,7 @@ public class UpdateChecker {
         final Request request = new Request.Builder()
                 .url(UPDATE_JSON_URL)
                 .header("Cache-Control", "no-cache")
+                .header("User-Agent", "JabJab/" + BuildConfig.VERSION_NAME)
                 .build();
         final Response response;
         try {
@@ -223,7 +224,9 @@ public class UpdateChecker {
 
         // Resume: if a partial file exists, ask the server to continue from where we left off.
         final long existingBytes = apkFile.exists() ? apkFile.length() : 0;
-        final Request.Builder requestBuilder = new Request.Builder().url(downloadUrl);
+        final Request.Builder requestBuilder = new Request.Builder()
+                .url(downloadUrl)
+                .header("User-Agent", "JabJab/" + BuildConfig.VERSION_NAME);
         if (existingBytes > 0) {
             requestBuilder.header("Range", "bytes=" + existingBytes + "-");
             Log.d(Config.LOGTAG, "update: resuming download from byte " + existingBytes);
diff --git a/src/main/java/tel/xmpp/jabjab/ui/widget/ClickableMovementMethod.java b/src/main/java/tel/xmpp/jabjab/ui/widget/ClickableMovementMethod.java
index c020f86..fc71f5f 100644
--- a/src/main/java/tel/xmpp/jabjab/ui/widget/ClickableMovementMethod.java
+++ b/src/main/java/tel/xmpp/jabjab/ui/widget/ClickableMovementMethod.java
@@ -11,20 +11,23 @@ public class ClickableMovementMethod extends ArrowKeyMovementMethod {
 
 	@Override
 	public boolean onTouchEvent(TextView widget, Spannable buffer, MotionEvent event) {
-		// Just copied from android.text.method.LinkMovementMethod
-		if (event.getAction() == MotionEvent.ACTION_UP) {
+		final int action = event.getAction();
+		if (action == MotionEvent.ACTION_DOWN || action == MotionEvent.ACTION_UP) {
 			int x = (int) event.getX();
 			int y = (int) event.getY();
 			x -= widget.getTotalPaddingLeft();
 			y -= widget.getTotalPaddingTop();
 			x += widget.getScrollX();
 			y += widget.getScrollY();
-			Layout layout = widget.getLayout();
-			int line = layout.getLineForVertical(y);
-			int off = layout.getOffsetForHorizontal(line, x);
-			ClickableSpan[] link = buffer.getSpans(off, off, ClickableSpan.class);
+			final Layout layout = widget.getLayout();
+			final int line = layout.getLineForVertical(y);
+			final int off = layout.getOffsetForHorizontal(line, x);
+			final ClickableSpan[] link = buffer.getSpans(off, off, ClickableSpan.class);
 			if (link.length != 0) {
-				link[0].onClick(widget);
+				if (action == MotionEvent.ACTION_UP) {
+					link[0].onClick(widget);
+				}
+				// Claim the touch sequence on DOWN so parent ListView does not steal it.
 				return true;
 			}
 		}
diff --git a/src/main/java/tel/xmpp/jabjab/utils/GeoHelper.java b/src/main/java/tel/xmpp/jabjab/utils/GeoHelper.java
index 33f9d9d..5920b83 100644
--- a/src/main/java/tel/xmpp/jabjab/utils/GeoHelper.java
+++ b/src/main/java/tel/xmpp/jabjab/utils/GeoHelper.java
@@ -88,7 +88,10 @@ public class GeoHelper {
     }
 
     public static Intent showLocationIntent(final Context context, final Message message) {
-        final GeoPoint geoPoint = GeoHelper.parseGeoPoint(message.getBody());
+        return showLocationIntent(context, GeoHelper.parseGeoPoint(message.getBody()));
+    }
+
+    public static Intent showLocationIntent(final Context context, final GeoPoint geoPoint) {
         final Intent intent = new Intent(context, ShowLocationActivity.class);
         intent.setAction(ShowLocationActivity.ACTION_SHOW_LOCATION);
         intent.putExtra("latitude", geoPoint.getLatitude());
diff --git a/src/main/java/tel/xmpp/jabjab/utils/UIHelper.java b/src/main/java/tel/xmpp/jabjab/utils/UIHelper.java
index fa78fd7..c431283 100644
--- a/src/main/java/tel/xmpp/jabjab/utils/UIHelper.java
+++ b/src/main/java/tel/xmpp/jabjab/utils/UIHelper.java
@@ -42,6 +42,42 @@ public class UIHelper {
     private static final int FULL_DATE_FLAGS =
             DateUtils.FORMAT_SHOW_TIME | DateUtils.FORMAT_ABBREV_ALL | DateUtils.FORMAT_SHOW_DATE;
 
+    /**
+     * Formats a timestamp for the channel card footer:
+     * "Saturday, July 4, 19:09 (3 hours ago)"
+     * Uses the device's local time zone and 12/24-hour preference.
+     */
+    public static String readableChannelCardTime(final Context context, final long time) {
+        if (time == 0) return context.getString(R.string.just_now);
+        final Date date = new Date(time);
+        final long diffSec = (System.currentTimeMillis() - time) / 1000;
+
+        // Full weekday + month day + clock time
+        final java.text.SimpleDateFormat dayFmt =
+                new java.text.SimpleDateFormat("EEEE, MMMM d", java.util.Locale.getDefault());
+        final java.text.DateFormat timeFmt = DateFormat.getTimeFormat(context);
+        final String datePart = dayFmt.format(date) + ", " + timeFmt.format(date);
+
+        // Relative suffix
+        final String relative;
+        if (diffSec < 60) {
+            relative = context.getString(R.string.just_now);
+        } else if (diffSec < 3600) {
+            final long mins = Math.round(diffSec / 60.0);
+            relative = context.getString(R.string.minutes_ago, mins);
+        } else if (diffSec < 86400 * 2) {
+            final long hours = Math.round(diffSec / 3600.0);
+            relative = context.getString(R.string.hours_ago, hours);
+        } else {
+            final long days = diffSec / 86400;
+            relative = context.getString(R.string.days_ago, days);
+        }
+
+        // "just now" alone looks fine; otherwise append in parentheses
+        if (diffSec < 60) return datePart + " (" + relative + ")";
+        return datePart + " (" + relative + ")";
+    }
+
     public static String readableTimeDifference(Context context, long time) {
         return readableTimeDifference(context, time, false);
     }
diff --git a/src/main/java/tel/xmpp/jabjab/worker/ExportBackupWorker.java b/src/main/java/tel/xmpp/jabjab/worker/ExportBackupWorker.java
index c8f494f..2c4add0 100644
--- a/src/main/java/tel/xmpp/jabjab/worker/ExportBackupWorker.java
+++ b/src/main/java/tel/xmpp/jabjab/worker/ExportBackupWorker.java
@@ -3,6 +3,7 @@ package tel.xmpp.jabjab.worker;
 import static tel.xmpp.jabjab.utils.Compatibility.s;
 
 import android.app.Notification;
+import android.app.NotificationChannel;
 import android.app.NotificationManager;
 import android.app.PendingIntent;
 import android.content.Context;
@@ -119,9 +120,21 @@ public class ExportBackupWorker extends Worker {
         return Result.success();
     }
 
-    @NonNull
-    @Override
+    private void ensureBackupNotificationChannel() {
+        final var nm = getApplicationContext().getSystemService(NotificationManager.class);
+        if (nm.getNotificationChannel("backup") == null) {
+            final var channel =
+                    new NotificationChannel(
+                            "backup",
+                            getApplicationContext().getString(R.string.backup_channel_name),
+                            NotificationManager.IMPORTANCE_LOW);
+            channel.setShowBadge(false);
+            nm.createNotificationChannel(channel);
+        }
+    }
+
     public ForegroundInfo getForegroundInfo() {
+        ensureBackupNotificationChannel();
         Log.d(Config.LOGTAG, "getForegroundInfo()");
         final NotificationCompat.Builder notification = getNotification();
         if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.Q) {
diff --git a/src/main/java/tel/xmpp/jabjab/worker/ImportBackupWorker.java b/src/main/java/tel/xmpp/jabjab/worker/ImportBackupWorker.java
index 7cb7927..c5b0718 100644
--- a/src/main/java/tel/xmpp/jabjab/worker/ImportBackupWorker.java
+++ b/src/main/java/tel/xmpp/jabjab/worker/ImportBackupWorker.java
@@ -3,6 +3,7 @@ package tel.xmpp.jabjab.worker;
 import static tel.xmpp.jabjab.utils.Compatibility.s;
 
 import android.app.Notification;
+import android.app.NotificationChannel;
 import android.app.NotificationManager;
 import android.app.PendingIntent;
 import android.content.ContentValues;
@@ -128,9 +129,24 @@ public class ImportBackupWorker extends Worker {
         return result;
     }
 
-    @NonNull
-    @Override
+    private void ensureBackupNotificationChannel() {
+        // The channel may not exist if XmppConnectionService has never started (e.g. first-run
+        // restore from setup wizard). Create it idempotently here so the foreground notification
+        // doesn't crash with "No Channel found".
+        final var nm = getApplicationContext().getSystemService(NotificationManager.class);
+        if (nm.getNotificationChannel("backup") == null) {
+            final var channel =
+                    new NotificationChannel(
+                            "backup",
+                            getApplicationContext().getString(R.string.backup_channel_name),
+                            NotificationManager.IMPORTANCE_LOW);
+            channel.setShowBadge(false);
+            nm.createNotificationChannel(channel);
+        }
+    }
+
     public ForegroundInfo getForegroundInfo() {
+        ensureBackupNotificationChannel();
         final var notification = createImportBackupNotification(1, 0);
         if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.Q) {
             return new ForegroundInfo(
@@ -352,20 +368,25 @@ public class ImportBackupWorker extends Worker {
                 .setContentText(context.getString(R.string.notification_restored_backup_subtitle))
                 .setAutoCancel(true)
                 .setSmallIcon(R.drawable.ic_unarchive_24dp);
-        if (QuickConversationsService.isConversations()
-                && AccountUtils.MANAGE_ACCOUNT_ACTIVITY != null) {
-            builder.setContentText(
-                    context.getString(R.string.notification_restored_backup_subtitle));
-            builder.setContentIntent(
-                    PendingIntent.getActivity(
-                            context,
-                            145,
-                            new Intent(context, AccountUtils.MANAGE_ACCOUNT_ACTIVITY),
-                            s()
-                                    ? PendingIntent.FLAG_IMMUTABLE
-                                            | PendingIntent.FLAG_UPDATE_CURRENT
-                                    : PendingIntent.FLAG_UPDATE_CURRENT));
-        }
+        // Always add a tap intent — use MANAGE_ACCOUNT_ACTIVITY if available (Conversations
+        // flavor), otherwise fall back to ConversationActivity. This ensures tapping the
+        // "restore done" notification always opens the app correctly.
+        final Class<?> targetActivity =
+                (QuickConversationsService.isConversations()
+                                && AccountUtils.MANAGE_ACCOUNT_ACTIVITY != null)
+                        ? AccountUtils.MANAGE_ACCOUNT_ACTIVITY
+                        : tel.xmpp.jabjab.ui.ConversationActivity.class;
+        builder.setContentIntent(
+                PendingIntent.getActivity(
+                        context,
+                        145,
+                        new Intent(context, targetActivity)
+                                .addFlags(
+                                        Intent.FLAG_ACTIVITY_NEW_TASK
+                                                | Intent.FLAG_ACTIVITY_CLEAR_TASK),
+                        s()
+                                ? PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_UPDATE_CURRENT
+                                : PendingIntent.FLAG_UPDATE_CURRENT));
         getApplicationContext()
                 .getSystemService(NotificationManager.class)
                 .notify(NOTIFICATION_ID + 2, builder.build());
diff --git a/src/main/java/tel/xmpp/jabjab/xmpp/XmppConnection.java b/src/main/java/tel/xmpp/jabjab/xmpp/XmppConnection.java
index 0eea995..0ab49e7 100644
--- a/src/main/java/tel/xmpp/jabjab/xmpp/XmppConnection.java
+++ b/src/main/java/tel/xmpp/jabjab/xmpp/XmppConnection.java
@@ -1244,11 +1244,15 @@ public class XmppConnection implements Runnable {
                 account.getJid().asBareJid() + ": resending " + failedStanzas.size() + " stanzas");
         for (final Stanza packet : failedStanzas) {
             if (packet instanceof im.conversations.android.xmpp.model.stanza.Message message) {
-                mXmppConnectionService.markMessage(
-                        account,
-                        message.getTo().asBareJid(),
-                        message.getId(),
-                        Message.STATUS_UNSEND);
+                // message.getTo() can be null for stanzas sent without an explicit 'to' attribute
+                final Jid to = message.getTo();
+                if (to != null) {
+                    mXmppConnectionService.markMessage(
+                            account,
+                            to.asBareJid(),
+                            message.getId(),
+                            Message.STATUS_UNSEND);
+                }
             }
             sendPacket(packet);
         }
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 c33fd51..d81eb09 100644
--- a/src/main/java/tel/xmpp/jabjab/xmpp/manager/BlogManager.java
+++ b/src/main/java/tel/xmpp/jabjab/xmpp/manager/BlogManager.java
@@ -6,10 +6,12 @@ import com.google.common.util.concurrent.Futures;
 import com.google.common.util.concurrent.ListenableFuture;
 import com.google.common.util.concurrent.MoreExecutors;
 import tel.xmpp.jabjab.Config;
+import tel.xmpp.jabjab.services.XmppConnectionService;
 import tel.xmpp.jabjab.xmpp.Jid;
 import tel.xmpp.jabjab.xmpp.XmppConnection;
 import im.conversations.android.xmpp.NodeConfiguration;
 import im.conversations.android.xmpp.model.atom.Entry;
+import im.conversations.android.xmpp.model.pubsub.Items;
 import java.time.Instant;
 import java.time.format.DateTimeFormatter;
 import java.util.ArrayList;
@@ -19,10 +21,33 @@ import java.util.UUID;
 
 public class BlogManager extends AbstractManager {
 
+    // SharedPreferences file + key for the unread-blog badge in BottomNavHelper.
+    public static final String BADGE_PREFS  = "jabjab_blog_badge";
+    public static final String KEY_BADGE_TS = "badge_ts";
+
     public static final String BLOG_NODE = "urn:xmpp:microblog:0";
 
-    public BlogManager(final Context context, final XmppConnection connection) {
-        super(context, connection);
+    private final XmppConnectionService service;
+
+    public BlogManager(final XmppConnectionService service, final XmppConnection connection) {
+        super(service, connection);
+        this.service = service;
+    }
+
+    /**
+     * Called by PubSubManager when a PEP event arrives for the blog node from a contact.
+     * Bumps the unread-badge timestamp so the Blog nav tab lights up.
+     */
+    public void handleIncomingItems(final Jid from, final Items items) {
+        final Map<String, Entry> entryMap = items.getItemMap(Entry.class);
+        Log.d(Config.LOGTAG, "BLOG push from " + from + ": " + entryMap.size() + " items");
+        if (!entryMap.isEmpty()) {
+            service.getApplicationContext()
+                    .getSharedPreferences(BADGE_PREFS, Context.MODE_PRIVATE)
+                    .edit()
+                    .putLong(KEY_BADGE_TS, System.currentTimeMillis())
+                    .apply();
+        }
     }
 
     public ListenableFuture<String> publishPost(final String title, final String body) {
diff --git a/src/main/java/tel/xmpp/jabjab/xmpp/manager/PubSubManager.java b/src/main/java/tel/xmpp/jabjab/xmpp/manager/PubSubManager.java
index 12ddeef..4d23116 100644
--- a/src/main/java/tel/xmpp/jabjab/xmpp/manager/PubSubManager.java
+++ b/src/main/java/tel/xmpp/jabjab/xmpp/manager/PubSubManager.java
@@ -198,6 +198,13 @@ public class PubSubManager extends AbstractManager {
         if (isFromBare && Namespace.AXOLOTL_DEVICE_LIST.equals(node)) {
             getManager(AxolotlManager.class).handleItems(from, items);
         }
+        // Real-time PEP push from contacts publishing stories or blog posts
+        if (isFromBare && StoriesManager.STORIES_NODE.equals(node)) {
+            getManager(StoriesManager.class).handleIncomingItems(from, items);
+        }
+        if (isFromBare && BlogManager.BLOG_NODE.equals(node)) {
+            getManager(BlogManager.class).handleIncomingItems(from, items);
+        }
     }
 
     private void handlePurge(final Message message, final Purge purge) {
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 29db320..8b06fdd 100644
--- a/src/main/java/tel/xmpp/jabjab/xmpp/manager/StoriesManager.java
+++ b/src/main/java/tel/xmpp/jabjab/xmpp/manager/StoriesManager.java
@@ -3,13 +3,17 @@ package tel.xmpp.jabjab.xmpp.manager;
 import android.content.Context;
 import android.util.Log;
 import com.google.common.util.concurrent.Futures;
+import com.google.common.util.concurrent.FutureCallback;
 import com.google.common.util.concurrent.ListenableFuture;
 import com.google.common.util.concurrent.MoreExecutors;
 import tel.xmpp.jabjab.Config;
+import tel.xmpp.jabjab.persistance.DatabaseBackend;
+import tel.xmpp.jabjab.services.XmppConnectionService;
 import tel.xmpp.jabjab.xmpp.Jid;
 import tel.xmpp.jabjab.xmpp.XmppConnection;
 import im.conversations.android.xmpp.NodeConfiguration;
 import im.conversations.android.xmpp.model.atom.Entry;
+import im.conversations.android.xmpp.model.pubsub.Items;
 import im.conversations.android.xmpp.model.stories.Story;
 import java.io.File;
 import java.time.Instant;
@@ -22,6 +26,12 @@ import java.util.UUID;
 
 public class StoriesManager extends AbstractManager {
 
+    // SharedPreferences file + keys used for the unread-story badge in BottomNavHelper.
+    // badge_ts: epoch-ms of the newest story we have seen arrive (via push or fetch).
+    // last_read_ts: epoch-ms when the user last opened StoriesActivity; cleared there.
+    public static final String BADGE_PREFS     = "jabjab_story_badge";
+    public static final String KEY_BADGE_TS    = "badge_ts";
+
     // Monocles / pubsub-social-feed compatible node name
     public static final String STORIES_NODE  = "urn:xmpp:pubsub-social-feed:stories:0";
     public static final int STORY_MAX_COUNT = 14;
@@ -31,8 +41,46 @@ public class StoriesManager extends AbstractManager {
     public static final long MAX_VIDEO_SIZE_BYTES = 8L * 1024 * 1024;
     public static final long MAX_IMAGE_SIZE_BYTES = 5L * 1024 * 1024;
 
-    public StoriesManager(final Context context, final XmppConnection connection) {
-        super(context, connection);
+    private final XmppConnectionService service;
+
+    public StoriesManager(final XmppConnectionService service, final XmppConnection connection) {
+        super(service, connection);
+        this.service = service;
+    }
+
+    /**
+     * Called by PubSubManager when a PEP event arrives for the stories node from a contact.
+     * Updates the unread-badge timestamp and notifies any registered StoriesActivity.
+     */
+    public void handleIncomingItems(final Jid from, final Items items) {
+        final Map<String, Entry> entryMap = items.getItemMap(Entry.class);
+        Log.d(Config.LOGTAG, "STORIES push from " + from + ": " + entryMap.size() + " items");
+        final String accountJid = getAccount().getJid().asBareJid().toString();
+        final String contactJid = from.asBareJid().toString();
+        // Parse and cache the pushed items into the DB
+        final LinkedHashMap<String, Story> stories = new LinkedHashMap<>();
+        for (final Map.Entry<String, Entry> e : entryMap.entrySet()) {
+            final Story story = Story.fromEntry(e.getValue());
+            if (story != null && story.isLive()) stories.put(e.getKey(), story);
+        }
+        if (!stories.isEmpty()) {
+            // Merge into the existing cache (don't replace — a push event carries only
+            // the newly published items, not the full node contents).
+            final DatabaseBackend db = DatabaseBackend.getInstance(context);
+            // Fetch the existing cache and merge new stories on top
+            final Map<String, LinkedHashMap<String, Story>> existing =
+                    db.getStoryCacheForAccount(accountJid);
+            final LinkedHashMap<String, Story> merged =
+                    existing.getOrDefault(contactJid, new LinkedHashMap<>());
+            merged.putAll(stories);
+            db.replaceStoryCacheForContact(accountJid, contactJid, merged);
+            service.getApplicationContext()
+                    .getSharedPreferences(BADGE_PREFS, Context.MODE_PRIVATE)
+                    .edit()
+                    .putLong(KEY_BADGE_TS, System.currentTimeMillis())
+                    .apply();
+            service.updateStoriesUi();
+        }
     }
 
     public ListenableFuture<Void> publishStory(
@@ -138,4 +186,52 @@ public class StoriesManager extends AbstractManager {
                 iq -> (Void) null,
                 MoreExecutors.directExecutor());
     }
+
+    /**
+     * Fetches stories for bareJid from PubSub, writes them to the local DB cache,
+     * and notifies any open StoriesActivity to reload. Called in the background by
+     * AccountStateProcessor (account goes online) and PresenceManager (contact comes online).
+     */
+    public void fetchAndCache(final Jid bareJid) {
+        final String accountJid = getAccount().getJid().asBareJid().toString();
+        final String contactJid = bareJid.asBareJid().toString();
+        Log.d(Config.LOGTAG, "STORIES fetchAndCache: " + contactJid + " for account " + accountJid);
+        Futures.addCallback(
+                fetchStoriesWithIds(bareJid),
+                new FutureCallback<LinkedHashMap<String, Story>>() {
+                    @Override
+                    public void onSuccess(final LinkedHashMap<String, Story> stories) {
+                        Log.d(Config.LOGTAG, "STORIES fetchAndCache success: "
+                                + contactJid + " → " + stories.size() + " stories");
+                        final DatabaseBackend db = DatabaseBackend.getInstance(context);
+                        db.replaceStoryCacheForContact(accountJid, contactJid, stories);
+                        // Bump badge timestamp if there are live stories
+                        if (!stories.isEmpty()) {
+                            service.getApplicationContext()
+                                    .getSharedPreferences(BADGE_PREFS, Context.MODE_PRIVATE)
+                                    .edit()
+                                    .putLong(KEY_BADGE_TS, System.currentTimeMillis())
+                                    .apply();
+                        }
+                        service.updateStoriesUi();
+                    }
+                    @Override
+                    public void onFailure(final Throwable t) {
+                        final String msg = t.getMessage();
+                        // item-not-found just means the contact has no stories node — normal
+                        if (msg == null || !msg.contains("item-not-found")) {
+                            Log.d(Config.LOGTAG, "STORIES fetchAndCache failed for "
+                                    + contactJid + ": " + msg);
+                        }
+                    }
+                },
+                MoreExecutors.directExecutor());
+    }
+
+    /**
+     * Convenience: fetch and cache stories for our own account JID.
+     */
+    public void fetchAndCacheSelf() {
+        fetchAndCache(getAccount().getJid().asBareJid());
+    }
 }
diff --git a/src/main/res/layout/activity_import_backup.xml b/src/main/res/layout/activity_import_backup.xml
index d3f9762..1bd0934 100644
--- a/src/main/res/layout/activity_import_backup.xml
+++ b/src/main/res/layout/activity_import_backup.xml
@@ -25,12 +25,23 @@
             android:layout_width="match_parent"
             android:layout_height="match_parent"
             android:gravity="center"
+            android:orientation="vertical"
+            android:padding="32dp"
             android:visibility="gone">
 
             <ProgressBar
                 android:layout_width="wrap_content"
                 android:layout_height="wrap_content"
                 android:layout_gravity="center" />
+
+            <TextView
+                android:layout_width="wrap_content"
+                android:layout_height="wrap_content"
+                android:layout_marginTop="24dp"
+                android:gravity="center"
+                android:text="@string/restore_minimize_hint"
+                android:textAppearance="?attr/textAppearanceBodyMedium"
+                android:alpha="0.65" />
         </LinearLayout>
 
 
diff --git a/src/main/res/layout/activity_story_view.xml b/src/main/res/layout/activity_story_view.xml
index c46e90a..576b09c 100644
--- a/src/main/res/layout/activity_story_view.xml
+++ b/src/main/res/layout/activity_story_view.xml
@@ -37,19 +37,21 @@
             android:layout_marginBottom="8dp"
             android:orientation="horizontal" />
 
-        <!-- Sender row -->
-        <LinearLayout
+        <!-- Sender row: avatar+name on left, counter centred, icons on right -->
+        <androidx.constraintlayout.widget.ConstraintLayout
+            xmlns:app="http://schemas.android.com/apk/res-auto"
             android:layout_width="match_parent"
-            android:layout_height="wrap_content"
-            android:gravity="center_vertical"
-            android:orientation="horizontal">
+            android:layout_height="48dp">
 
             <ImageView
                 android:id="@+id/story_sender_avatar"
                 android:layout_width="36dp"
                 android:layout_height="36dp"
                 android:contentDescription="@null"
-                android:scaleType="centerCrop" />
+                android:scaleType="centerCrop"
+                app:layout_constraintStart_toStartOf="parent"
+                app:layout_constraintTop_toTopOf="parent"
+                app:layout_constraintBottom_toBottomOf="parent" />
 
             <TextView
                 android:id="@+id/story_sender_name"
@@ -57,55 +59,82 @@
                 android:layout_width="0dp"
                 android:layout_height="wrap_content"
                 android:layout_marginStart="8dp"
-                android:layout_weight="1"
-                android:textColor="#FFFFFFFF" />
-
+                android:layout_marginEnd="8dp"
+                android:textColor="#FFFFFFFF"
+                android:ellipsize="end"
+                android:maxLines="1"
+                app:layout_constraintStart_toEndOf="@id/story_sender_avatar"
+                app:layout_constraintEnd_toStartOf="@id/story_counter"
+                app:layout_constraintTop_toTopOf="parent"
+                app:layout_constraintBottom_toBottomOf="parent" />
+
+            <!-- Counter centred in the row -->
             <TextView
                 android:id="@+id/story_counter"
                 style="?textAppearanceLabelSmall"
                 android:layout_width="wrap_content"
                 android:layout_height="wrap_content"
-                android:layout_marginEnd="4dp"
-                android:textColor="#CCFFFFFF" />
+                android:textColor="#CCFFFFFF"
+                app:layout_constraintStart_toStartOf="parent"
+                app:layout_constraintEnd_toEndOf="parent"
+                app:layout_constraintTop_toTopOf="parent"
+                app:layout_constraintBottom_toBottomOf="parent" />
 
+            <!-- Right-side icons: 48dp touch targets with padding for easy tapping -->
             <ImageButton
                 android:id="@+id/story_pause"
-                android:layout_width="wrap_content"
-                android:layout_height="wrap_content"
+                android:layout_width="48dp"
+                android:layout_height="48dp"
+                android:padding="12dp"
                 android:background="?selectableItemBackgroundBorderless"
                 android:contentDescription="@string/pause"
                 android:src="@drawable/ic_pause_24dp"
-                android:tint="#FFFFFFFF" />
+                android:tint="#FFFFFFFF"
+                app:layout_constraintEnd_toStartOf="@id/story_share"
+                app:layout_constraintTop_toTopOf="parent"
+                app:layout_constraintBottom_toBottomOf="parent" />
 
             <ImageButton
                 android:id="@+id/story_share"
-                android:layout_width="wrap_content"
-                android:layout_height="wrap_content"
+                android:layout_width="48dp"
+                android:layout_height="48dp"
+                android:padding="12dp"
                 android:background="?selectableItemBackgroundBorderless"
                 android:contentDescription="@string/share"
                 android:src="@drawable/ic_share_24dp"
-                android:tint="#FFFFFFFF" />
+                android:tint="#FFFFFFFF"
+                app:layout_constraintEnd_toStartOf="@id/story_delete"
+                app:layout_constraintTop_toTopOf="parent"
+                app:layout_constraintBottom_toBottomOf="parent" />
 
             <ImageButton
                 android:id="@+id/story_delete"
-                android:layout_width="wrap_content"
-                android:layout_height="wrap_content"
+                android:layout_width="48dp"
+                android:layout_height="48dp"
+                android:padding="12dp"
                 android:background="?selectableItemBackgroundBorderless"
                 android:contentDescription="@string/delete"
                 android:src="@drawable/ic_delete_24dp"
                 android:tint="#FFFFFFFF"
-                android:visibility="gone" />
+                android:visibility="gone"
+                app:layout_constraintEnd_toStartOf="@id/story_close"
+                app:layout_constraintTop_toTopOf="parent"
+                app:layout_constraintBottom_toBottomOf="parent" />
 
             <ImageButton
                 android:id="@+id/story_close"
-                android:layout_width="wrap_content"
-                android:layout_height="wrap_content"
+                android:layout_width="48dp"
+                android:layout_height="48dp"
+                android:padding="12dp"
                 android:background="?selectableItemBackgroundBorderless"
                 android:contentDescription="@string/close"
                 android:src="@drawable/ic_close_24dp"
-                android:tint="#FFFFFFFF" />
+                android:tint="#FFFFFFFF"
+                app:layout_constraintEnd_toEndOf="parent"
+                app:layout_constraintTop_toTopOf="parent"
+                app:layout_constraintBottom_toBottomOf="parent" />
 
-        </LinearLayout>
+        </androidx.constraintlayout.widget.ConstraintLayout>
 
     </LinearLayout>
 
diff --git a/src/main/res/layout/item_message_channel_card.xml b/src/main/res/layout/item_message_channel_card.xml
index e290491..c6a9a60 100644
--- a/src/main/res/layout/item_message_channel_card.xml
+++ b/src/main/res/layout/item_message_channel_card.xml
@@ -98,6 +98,17 @@
 
                 </LinearLayout>
 
+                <!-- Location button — shown when the card body contains a geo: link -->
+                <com.google.android.material.button.MaterialButton
+                    android:id="@+id/channel_card_location_button"
+                    style="@style/Widget.Material3.Button.TonalButton"
+                    android:layout_width="match_parent"
+                    android:layout_height="wrap_content"
+                    android:layout_marginTop="8dp"
+                    android:visibility="gone"
+                    app:icon="@drawable/ic_location_pin_24dp"
+                    app:iconGravity="textStart" />
+
                 <!-- Timestamp -->
                 <TextView
                     android:id="@+id/channel_card_time"
diff --git a/src/main/res/values/strings.xml b/src/main/res/values/strings.xml
index 887db66..2e9e38f 100644
--- a/src/main/res/values/strings.xml
+++ b/src/main/res/values/strings.xml
@@ -55,6 +55,8 @@
     <string name="just_now">just now</string>
     <string name="minute_ago">1 min ago</string>
     <string name="minutes_ago">%d mins ago</string>
+    <string name="hours_ago">%d hours ago</string>
+    <string name="days_ago">%d days ago</string>
     <plurals name="x_unread_conversations">
         <item quantity="one">%d unread chat</item>
         <item quantity="other">%d unread chats</item>
@@ -933,6 +935,7 @@
     <string name="unable_to_restore_backup">Could not restore backup.</string>
     <string name="unable_to_decrypt_backup">Could not decrypt backup. Is the password correct?</string>
     <string name="backup_channel_name">Backup &amp; Restore</string>
+    <string name="restore_minimize_hint">Restoring your backup. This may take a few minutes for large accounts. You can minimize this screen and watch the notification bar. JabJab will notify you when it\'s done.</string>
     <string name="enter_jabber_id">Enter XMPP address</string>
     <string name="create_group_chat">Create group chat</string>
     <string name="join_public_channel">Join public channel</string>

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.