Story audiences (Android): all contacts, only chosen, or all except
Three modes, stored in the vault, applied at post time. 🔴 The audience is enforced by who is SENT the bytes, not by a flag. A story is sealed to each recipient's devices through the ratchet, so somebody outside the list is never sent it - there is no copy on the relay for them to be denied. Every mainstream product holds one server-side copy and decides who is shown it; this cannot work that way even if somebody wanted it to, and that is the feature rather than a limitation. Which also answers "make sure stories are only visible to friends": they already were, structurally. There is no public audience and there cannot be one - a story has to be encrypted to somebody in particular. ⚠️ The saved list is intersected with the live contact list in every branch, including "only chosen". An id left behind after that person was removed as a contact must not resurrect them as a recipient. The screen says what will actually happen - "your next story goes to 3 contacts" - resolved the same way, and refuses a post that would reach nobody rather than silently succeeding at nothing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
85d6825fdf
commit
780dc50652
@@ -31,6 +31,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.BasicTextField
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.Divider
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
@@ -692,6 +693,9 @@ fun ColumnScope.AccountScreen(
|
||||
|
||||
SettingsSection { ProfilePicture(model, phase) }
|
||||
|
||||
SectionBand()
|
||||
SettingsSection { StoryAudienceSection(model, phase) }
|
||||
|
||||
SectionBand()
|
||||
SettingsSection { AddressCard(fnid = phase.fnid, relay = phase.relay) }
|
||||
|
||||
@@ -831,6 +835,119 @@ fun ColumnScope.AccountScreen(
|
||||
* means telling every contact again. That trade is stated on the screen rather
|
||||
* than left for somebody to discover when a new contact sees initials.
|
||||
*/
|
||||
/**
|
||||
* Who may see this account's stories.
|
||||
*
|
||||
* 🔴 **Enforced by who is SENT the bytes.** A story is sealed to each
|
||||
* recipient's devices through the ratchet, so somebody outside this list is
|
||||
* never sent it — there is no copy on the relay for them to be denied. Every
|
||||
* mainstream product holds one copy server-side and decides who is shown it;
|
||||
* this cannot work that way even if somebody wanted it to, and that is the
|
||||
* feature.
|
||||
*
|
||||
* ⚠️ There is no "public" option and cannot be one. A story only ever goes to
|
||||
* contacts, because it has to be encrypted to somebody in particular.
|
||||
*/
|
||||
@Composable
|
||||
private fun ColumnScope.StoryAudienceSection(
|
||||
model: MessengerViewModel,
|
||||
phase: MessengerViewModel.Phase.SignedIn,
|
||||
) {
|
||||
val audience = phase.storyAudience
|
||||
Text("Who can see your stories", style = MaterialTheme.typography.titleMedium)
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(vertical = Space.sm),
|
||||
horizontalArrangement = Arrangement.spacedBy(Space.sm),
|
||||
) {
|
||||
KitChip(
|
||||
text = "All contacts",
|
||||
selected = audience.mode == Session.AudienceMode.EVERYONE,
|
||||
onClick = {
|
||||
model.setStoryAudience(
|
||||
Session.StoryAudience(Session.AudienceMode.EVERYONE, audience.userIds))
|
||||
},
|
||||
tag = "audience-everyone",
|
||||
)
|
||||
KitChip(
|
||||
text = "Only chosen",
|
||||
selected = audience.mode == Session.AudienceMode.ONLY,
|
||||
onClick = {
|
||||
model.setStoryAudience(
|
||||
Session.StoryAudience(Session.AudienceMode.ONLY, audience.userIds))
|
||||
},
|
||||
tag = "audience-only",
|
||||
)
|
||||
KitChip(
|
||||
text = "All except",
|
||||
selected = audience.mode == Session.AudienceMode.EXCEPT,
|
||||
onClick = {
|
||||
model.setStoryAudience(
|
||||
Session.StoryAudience(Session.AudienceMode.EXCEPT, audience.userIds))
|
||||
},
|
||||
tag = "audience-except",
|
||||
)
|
||||
}
|
||||
|
||||
Text(
|
||||
when (audience.mode) {
|
||||
Session.AudienceMode.EVERYONE ->
|
||||
"Every contact receives your stories. Nobody else can — a story " +
|
||||
"is encrypted to the people it goes to, so there is no copy " +
|
||||
"for a stranger to find."
|
||||
Session.AudienceMode.ONLY ->
|
||||
"Only the people ticked below are sent your stories."
|
||||
Session.AudienceMode.EXCEPT ->
|
||||
"Every contact except the people ticked below."
|
||||
},
|
||||
fontSize = 14.sp,
|
||||
color = Palette.SecondaryText,
|
||||
)
|
||||
|
||||
// The list only earns its place when the mode uses one.
|
||||
if (audience.mode != Session.AudienceMode.EVERYONE) {
|
||||
if (phase.buddies.isEmpty()) {
|
||||
Text("Add a contact first.", fontSize = 14.sp, color = Palette.SecondaryText)
|
||||
}
|
||||
phase.buddies.forEach { buddy ->
|
||||
val name = buddy.displayName.ifBlank { buddy.handle }
|
||||
val ticked = buddy.userId in audience.userIds
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable {
|
||||
val next = if (ticked) audience.userIds - buddy.userId
|
||||
else audience.userIds + buddy.userId
|
||||
model.setStoryAudience(audience.copy(userIds = next))
|
||||
}
|
||||
.padding(vertical = Space.sm)
|
||||
.testTag("audience-${buddy.userId}"),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(Kit.gutter),
|
||||
) {
|
||||
Checkbox(checked = ticked, onCheckedChange = null)
|
||||
Text(name, fontSize = 16.sp, modifier = Modifier.weight(1f))
|
||||
}
|
||||
}
|
||||
|
||||
// ⚠️ Says what will actually happen, resolved against the real contact
|
||||
// list — an id left over from a removed contact must not read as a
|
||||
// recipient, and the resolver drops it.
|
||||
val reach = audience.recipients(phase.buddies.map { it.userId }).size
|
||||
Text(
|
||||
when (reach) {
|
||||
0 -> "This would go to nobody."
|
||||
1 -> "Your next story goes to 1 contact."
|
||||
else -> "Your next story goes to $reach contacts."
|
||||
},
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = if (reach == 0) Palette.Pink else Palette.SecondaryText,
|
||||
modifier = Modifier.padding(top = Space.sm).testTag("audience-reach"),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ColumnScope.ProfilePicture(
|
||||
model: MessengerViewModel,
|
||||
|
||||
@@ -103,6 +103,9 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
|
||||
|
||||
/** Calls placed and received, newest first. */
|
||||
val calls: List<Session.CallEntry> = emptyList(),
|
||||
|
||||
/** Who may see this account's stories. */
|
||||
val storyAudience: Session.StoryAudience = Session.StoryAudience(),
|
||||
val myAvatar: io.flamenet.e2e.Attachment.Ref? = null,
|
||||
/** Live stories, newest first (§6.6). */
|
||||
val stories: List<Vault.Story> = emptyList(),
|
||||
@@ -299,6 +302,7 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
|
||||
fetchedBuddies.getOrDefault(emptyList()).map { b -> b.userId }
|
||||
) ?: emptyMap(),
|
||||
calls = session?.callLog() ?: emptyList(),
|
||||
storyAudience = session?.storyAudience() ?: Session.StoryAudience(),
|
||||
myAvatar = session?.myAvatar(),
|
||||
groups = fetchedGroups.getOrDefault(emptyList()),
|
||||
groupsLoaded = fetchedGroups.isSuccess,
|
||||
@@ -1109,14 +1113,31 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
|
||||
* would need the relay to know who may read it, which is a list of your
|
||||
* contacts handed to the server for a feature that works without it.
|
||||
*/
|
||||
/** Change who may see this account's stories. */
|
||||
fun setStoryAudience(audience: Session.StoryAudience) = viewModelScope.launch {
|
||||
val current = session ?: return@launch
|
||||
withContext(Dispatchers.IO) { runCatching { current.saveStoryAudience(audience) } }
|
||||
update { it.copy(storyAudience = audience) }
|
||||
}
|
||||
|
||||
fun postStory(bytes: ByteArray, mime: String, width: Int, height: Int, caption: String?) =
|
||||
viewModelScope.launch {
|
||||
val current = session ?: return@launch
|
||||
val recipients = signedIn()?.buddies?.map { it.userId }.orEmpty()
|
||||
if (recipients.isEmpty()) {
|
||||
val contacts = signedIn()?.buddies?.map { it.userId }.orEmpty()
|
||||
if (contacts.isEmpty()) {
|
||||
update { it.copy(error = "Add a contact before posting a story.") }
|
||||
return@launch
|
||||
}
|
||||
// 🔴 The audience decides who is SENT the bytes. There is no copy
|
||||
// on the relay that somebody could be denied — see Session.StoryAudience.
|
||||
val recipients = current.storyAudience().recipients(contacts)
|
||||
if (recipients.isEmpty()) {
|
||||
update {
|
||||
it.copy(error = "Nobody is in your story audience, so this "
|
||||
+ "would go to no one. Change who can see your stories on Account.")
|
||||
}
|
||||
return@launch
|
||||
}
|
||||
update { it.copy(busy = true, error = null) }
|
||||
val done = withContext(Dispatchers.IO) {
|
||||
runCatching { current.postStory(bytes, mime, width, height, caption, recipients) }
|
||||
|
||||
@@ -262,6 +262,54 @@ class Session(
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Who may see this account's stories.
|
||||
*
|
||||
* `EVERYONE` means every contact — which is already the narrowest a story
|
||||
* can be without a list, because a story only ever goes to people who are
|
||||
* contacts. There is no public audience and cannot be one: a story is
|
||||
* sealed per recipient.
|
||||
*/
|
||||
enum class AudienceMode { EVERYONE, ONLY, EXCEPT }
|
||||
|
||||
data class StoryAudience(
|
||||
val mode: AudienceMode = AudienceMode.EVERYONE,
|
||||
val userIds: Set<Long> = emptySet(),
|
||||
) {
|
||||
/**
|
||||
* Resolve against the current contact list.
|
||||
*
|
||||
* ⚠️ Intersected with `contacts` in every branch, including ONLY. An
|
||||
* id left in a saved list after that person was removed as a contact
|
||||
* must not resurrect them as a recipient.
|
||||
*/
|
||||
fun recipients(contacts: List<Long>): List<Long> = when (mode) {
|
||||
AudienceMode.EVERYONE -> contacts
|
||||
AudienceMode.ONLY -> contacts.filter { it in userIds }
|
||||
AudienceMode.EXCEPT -> contacts.filter { it !in userIds }
|
||||
}
|
||||
}
|
||||
|
||||
fun storyAudience(): StoryAudience {
|
||||
val json = vault.storyAudience() ?: return StoryAudience()
|
||||
val mode = runCatching {
|
||||
AudienceMode.valueOf(json.optString("mode"))
|
||||
}.getOrDefault(AudienceMode.EVERYONE)
|
||||
val ids = json.optJSONArray("ids")
|
||||
val set = buildSet {
|
||||
for (i in 0 until (ids?.length() ?: 0)) add(ids!!.optLong(i))
|
||||
}
|
||||
return StoryAudience(mode, set)
|
||||
}
|
||||
|
||||
fun saveStoryAudience(audience: StoryAudience) {
|
||||
val ids = org.json.JSONArray()
|
||||
audience.userIds.forEach { ids.put(it) }
|
||||
vault.saveStoryAudience(JSONObject()
|
||||
.put("mode", audience.mode.name)
|
||||
.put("ids", ids))
|
||||
}
|
||||
|
||||
// MARK: - Outbox
|
||||
|
||||
/**
|
||||
|
||||
@@ -61,6 +61,7 @@ class Vault(private val storage: Storage, private val key: SecretKey) {
|
||||
private const val MY_AVATAR = "my-avatar"
|
||||
private const val OUTBOX = "outbox"
|
||||
private const val CALL_LOG = "call-log"
|
||||
private const val STORY_AUDIENCE = "story-audience"
|
||||
/** Kept because the whole log is re-sealed on every write. */
|
||||
private const val CALL_LOG_MAX = 500
|
||||
|
||||
@@ -554,6 +555,23 @@ class Vault(private val storage: Storage, private val key: SecretKey) {
|
||||
writeJson(CALL_LOG, JSONObject().put("calls", trimmed))
|
||||
}
|
||||
|
||||
/**
|
||||
* Who may see this account's stories.
|
||||
*
|
||||
* 🔴 **Enforced by who receives the bytes, not by a flag.** A story is
|
||||
* sealed to each recipient's devices through the ratchet, so somebody
|
||||
* outside the audience is never sent it at all — there is no copy on the
|
||||
* relay for them to be denied. That is a stronger guarantee than every
|
||||
* mainstream product, where the server holds one copy and decides who is
|
||||
* shown it, and it is worth not weakening later for convenience.
|
||||
*
|
||||
* ⚠️ In the vault. An audience list is a statement about who somebody
|
||||
* trusts, which is exactly the metadata this app exists to keep private.
|
||||
*/
|
||||
fun storyAudience(): JSONObject? = readJson(STORY_AUDIENCE)
|
||||
|
||||
fun saveStoryAudience(audience: JSONObject) = writeJson(STORY_AUDIENCE, audience)
|
||||
|
||||
/** Rewrite a thread wholesale, for restating a message's delivery state. */
|
||||
fun replaceHistory(threadId: String, messages: JSONArray) =
|
||||
writeJson(historyName(threadId), JSONObject().put("messages", messages))
|
||||
|
||||
Reference in New Issue
Block a user