Keep your own stories in a private archive
Android half of ios#54. Expired stories were gone from the author's device too, so there was no way to look back at what you posted. 🔴 This keeps something the interface told everybody would disappear, so the shape of the feature is the argument for it being acceptable: off unless switched on, labelled as what it is, and only ever holding stories this account posted. `Vault.archive` refuses anything else — the caller filters too, and this is the guard that survives the caller being rewritten. 🔴 The archive is deliberately NOT written through `saveStories`. Everything in it is expired by definition, and that path drops expired stories on write — so reusing it would empty the archive on the next pass. Separate record, and a test named for exactly that. Archiving happens in `stories()`, before `saveStories` drops the expired ones: the only moment they can be caught, since expired bytes leave on write. That runs on every load, so the fold is idempotent. ⚠️ The setting is injected into `Session` as a lambda. The engine layer has no Android dependency and must not read preferences, and an absent value has to read as OFF. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
ddd280092e
commit
242391119b
@@ -738,6 +738,9 @@ fun ColumnScope.AccountScreen(
|
||||
|
||||
SectionBand()
|
||||
SettingsSection { StoryAudienceSection(model, phase) }
|
||||
// ios#54 — beside the audience setting, because both answer "what happens
|
||||
// to my stories".
|
||||
SettingsSection { StoryArchiveSection(model, phase) }
|
||||
|
||||
SectionBand()
|
||||
SettingsSection { AppLockSection() }
|
||||
@@ -1110,6 +1113,67 @@ private fun storyLifetimeLabel(hours: Int): String = when {
|
||||
else -> (hours / 24).let { if (it == 1) "1 day" else "$it days" }
|
||||
}
|
||||
|
||||
/**
|
||||
* Stories this account posted, kept past their expiry (ios#54).
|
||||
*
|
||||
* 🔴 **Labelled as what it is, not as a feature.** This holds copies of things
|
||||
* the app told everybody would disappear. Somebody looking at it should be in
|
||||
* no doubt that these are kept, that they are kept only here, and that nobody
|
||||
* else's stories are or can be in it.
|
||||
*/
|
||||
@Composable
|
||||
fun ColumnScope.StoryArchiveSection(
|
||||
model: MessengerViewModel,
|
||||
phase: MessengerViewModel.Phase.SignedIn,
|
||||
) {
|
||||
LaunchedEffect(Unit) { model.refreshStoryArchive() }
|
||||
Text("Your story archive", style = MaterialTheme.typography.titleMedium)
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(vertical = Space.sm),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text("Keep my stories", modifier = Modifier.weight(1f))
|
||||
androidx.compose.material3.Switch(
|
||||
checked = phase.storyArchiveOn,
|
||||
onCheckedChange = { model.setStoryArchiveEnabled(it) },
|
||||
modifier = Modifier.testTag("story-archive-toggle"),
|
||||
)
|
||||
}
|
||||
Text(
|
||||
"Your stories disappear for everyone, including you. With this on, a copy of "
|
||||
+ "the ones YOU posted stays on this device after they expire. It never "
|
||||
+ "keeps anybody else's — theirs disappear as they were told they would.",
|
||||
fontSize = 12.sp,
|
||||
color = Palette.SecondaryText,
|
||||
)
|
||||
if (phase.storyArchive.isEmpty()) {
|
||||
Text(
|
||||
if (phase.storyArchiveOn) {
|
||||
"Nothing kept yet. Stories you post from now on appear here once they expire."
|
||||
} else {
|
||||
"Nothing kept. Turn the switch on to start keeping your own."
|
||||
},
|
||||
fontSize = 12.sp,
|
||||
color = Palette.SecondaryText,
|
||||
modifier = Modifier.padding(top = Space.sm),
|
||||
)
|
||||
} else {
|
||||
phase.storyArchive.forEach { kept ->
|
||||
Text(
|
||||
kept.caption.ifBlank { "No caption" },
|
||||
modifier = Modifier.padding(top = Space.sm).testTag("kept-${kept.id}"),
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.size(Space.sm))
|
||||
KitChip(
|
||||
text = "Delete everything kept",
|
||||
selected = false,
|
||||
onClick = { model.clearStoryArchive() },
|
||||
tag = "clear-story-archive",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ColumnScope.StoryAudienceSection(
|
||||
model: MessengerViewModel,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -149,6 +149,10 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
|
||||
|
||||
/** Who may see this account's stories. */
|
||||
val storyAudience: Session.StoryAudience = Session.StoryAudience(),
|
||||
/** Stories this account posted, kept past expiry (ios#54). */
|
||||
val storyArchive: List<Vault.Story> = emptyList(),
|
||||
/** Whether the archive is switched on (ios#54). */
|
||||
val storyArchiveOn: Boolean = false,
|
||||
val myAvatar: io.flamenet.e2e.Attachment.Ref? = null,
|
||||
/** Live stories, newest first (§6.6). */
|
||||
val stories: List<Vault.Story> = emptyList(),
|
||||
@@ -360,7 +364,13 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
|
||||
relay = relay,
|
||||
api = MessengerApi(
|
||||
credential.relayBaseUrl, credential.username, credential.appPassword),
|
||||
)
|
||||
).also {
|
||||
// ios#54 — injected, because the engine layer has no
|
||||
// Android dependency and must not read preferences.
|
||||
it.archiveEnabled = {
|
||||
MediaSettings(getApplication()).storyArchiveEnabled
|
||||
}
|
||||
}
|
||||
val fetchedBuddies = runCatching { session!!.roster() }
|
||||
val fetchedGroups = runCatching { session!!.namedGroups() }
|
||||
stored to Phase.SignedIn(
|
||||
@@ -570,7 +580,9 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
|
||||
vault = vault,
|
||||
relay = relay,
|
||||
api = io.flamenet.client.MessengerApi(config.baseUrl, account.username, credential),
|
||||
)
|
||||
).also {
|
||||
it.archiveEnabled = { MediaSettings(getApplication()).storyArchiveEnabled }
|
||||
}
|
||||
|
||||
// 🔴 **Our return address, before the first federated send.** A relay
|
||||
// that accepts a federated envelope records no sender, and our user id
|
||||
@@ -2017,6 +2029,36 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep your own stories after they expire (ios#54).
|
||||
*
|
||||
* 🔴 Off unless switched on. 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.
|
||||
*/
|
||||
fun setStoryArchiveEnabled(on: Boolean) = viewModelScope.launch {
|
||||
MediaSettings(getApplication()).storyArchiveEnabled = on
|
||||
update { it.copy(storyArchiveOn = on) }
|
||||
refreshStoryArchive()
|
||||
}
|
||||
|
||||
fun refreshStoryArchive() = viewModelScope.launch {
|
||||
val current = session ?: return@launch
|
||||
val on = MediaSettings(getApplication()).storyArchiveEnabled
|
||||
update { it.copy(storyArchiveOn = on) }
|
||||
val kept = withContext(Dispatchers.IO) {
|
||||
runCatching { current.storyArchive() }.getOrNull()
|
||||
} ?: return@launch
|
||||
update { it.copy(storyArchive = kept) }
|
||||
}
|
||||
|
||||
/** Empty the archive (ios#54). */
|
||||
fun clearStoryArchive() = viewModelScope.launch {
|
||||
val current = session ?: return@launch
|
||||
withContext(Dispatchers.IO) { runCatching { current.clearStoryArchive() } }
|
||||
update { it.copy(storyArchive = emptyList()) }
|
||||
}
|
||||
|
||||
/** Change who may see this account's stories. */
|
||||
fun setStoryAudience(audience: Session.StoryAudience) = viewModelScope.launch {
|
||||
val current = session ?: return@launch
|
||||
|
||||
@@ -887,7 +887,18 @@ class Session(
|
||||
/** Everything still live, newest first. */
|
||||
fun stories(): List<Vault.Story> {
|
||||
val now = System.currentTimeMillis() / 1000.0
|
||||
val live = vault.loadStories().filter { it.isActive(now) }
|
||||
val all = vault.loadStories()
|
||||
val live = all.filter { it.isActive(now) }
|
||||
// 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))
|
||||
}
|
||||
}
|
||||
// Written back so expired bytes leave the device rather than lingering
|
||||
// until the next post.
|
||||
//
|
||||
@@ -1105,6 +1116,21 @@ class Session(
|
||||
return reached
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the private story archive is switched on (ios#54).
|
||||
*
|
||||
* ⚠️ Injected rather than read from a context here: the engine 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())
|
||||
|
||||
/**
|
||||
* Members of a group we are in, or null if the relay does not list it for
|
||||
* us (audit H1). Cached for a short window: a poll pass can carry many
|
||||
|
||||
Reference in New Issue
Block a user