Keep your own stories in a private archive

Reinstates ios#54, taken by hand rather than cherry-picked. The original
commit was cut from a tree where story mute, text stories and story
replies all looked different from the versions rebuilt on this branch
today, so applying it whole would have left two implementations of each
under different names — no compile error, and exactly the kind of
divergence that rots quietly.

What came across is the archive and nothing else: the `archive()` rule,
the store, the sweep, and the setting.

🔴 The rule refuses anything not the author's own, and it does so in the
engine as well as at the caller. An archive holding somebody else's
expired story breaks the promise the app made to them, and "it is
private to you" does not repair that.

⚠️ The sweep runs at the one moment it can. Expired stories leave on
write, so if they are not caught before `saveStories` they are gone —
which is correct for everybody else's and wrong for your own.

`archiveEnabled` is injected rather than read from a context, because
this layer has no Android dependency and an unset value has to read as
OFF. This is a feature that keeps something the interface said would
disappear, so it stays off until somebody asks for it.

Vault story serialisation is now shared between the live set and the
archive rather than written twice.
This commit is contained in:
Paul Hitt
2026-09-03 19:13:56 -04:00
parent 9ca2c352e7
commit bee63e0ef7
4 changed files with 179 additions and 6 deletions
@@ -49,6 +49,18 @@ class MediaSettings(context: Context) {
/** Seconds, as [io.flamenet.client.Conversations.postStory] wants it. */
val storyLifetimeSeconds: Double get() = storyLifetimeHours * 60.0 * 60.0
/**
* Keep your own stories after they expire, in a private archive (ios#54).
*
* 🔴 **Off unless switched on, and an absent value reads as false.** This
* keeps something the interface told everybody would disappear, so it
* cannot be the quiet default — and it only ever holds stories this
* account posted.
*/
var storyArchiveEnabled: Boolean
get() = prefs.getBoolean("stories.archiveEnabled", false)
set(value) { prefs.edit().putBoolean("stories.archiveEnabled", value).apply() }
/**
* Refuse to send unless the relay can prove the recipient's keys appear in
* the log it publishes.
@@ -1064,6 +1064,21 @@ class Session(
if (forStory.isEmpty()) storyReactions.remove(storyKey)
}
/**
* Whether the private story archive is switched on (ios#54).
*
* ⚠️ Injected rather than read from a context here: this layer has no
* Android dependency, and an unset value must read as OFF — the archive
* keeps something the interface said would disappear.
*/
var archiveEnabled: () -> Boolean = { false }
/** Stories this account posted, kept past their expiry (ios#54). */
fun storyArchive(): List<Vault.Story> = vault.storyArchive()
/** Empty the archive (ios#54). */
fun clearStoryArchive() = vault.saveStoryArchive(emptyList())
fun reactionsFor(storyKey: String): Map<Long, String> = storyReactions[storyKey].orEmpty()
fun myReactionTo(storyKey: String): String? = storyReactions[storyKey]?.get(userId)
@@ -1119,7 +1134,18 @@ class Session(
/** Everything still live, newest first. */
fun stories(): List<Vault.Story> {
val now = System.currentTimeMillis() / 1000.0
val live = vault.loadStories()
val all = vault.loadStories()
// ios#54 — anything of YOUR OWN that has expired is archived here,
// before `saveStories` drops it. This is the only moment it can be
// caught: expired stories leave on write, so nothing downstream sees
// them again.
if (archiveEnabled()) {
val expiredMine = all.filter { it.mine && !it.isActive(now) }
if (expiredMine.isNotEmpty()) {
vault.saveStoryArchive(Vault.archive(vault.storyArchive(), expiredMine))
}
}
val live = all
.filter { it.isActive(now) }
// One place, so every screen agrees about whose stories are hidden.
.filter { it.mine || !storiesMuted(it.authorId) }