Android: stories
An image sealed and uploaded ONCE, then one ordinary story envelope down each contact's existing ratchet. Uploading per recipient would multiply the bytes by the size of the contact list for nothing, and would hand the relay a set of distinct blobs it could correlate by size and timing. ⚠️ **There is no story feed on the relay, and there should not be.** A server-side story needs the relay to know who may read it — which means giving it your contact list for a feature that works without one. The cost is that posting is a fan-out and partial delivery is normal: a contact with no registered device cannot receive anything, story or not, so the count is reported rather than the post failing. ⚠️ **A story bypasses the thread entirely.** It belongs to the strip, and filing it as a message would drop every story somebody posted into a conversation they did not have. One that arrives already expired — posted while this device was offline — is dropped rather than shown late. ⚠️ **Expired stories are deleted on WRITE, not filtered on read.** Filtering on read leaves a store that grows for ever and still holds every image anybody posted, which is the opposite of what an expiring story promises. The bytes actually leave the device. 🔴 **Expiry is a promise, not a guarantee, and the empty-state copy says so.** This client deletes the bytes when the clock runs out because it chooses to. Nothing stops a recipient's client keeping them. A UI implying otherwise would tell people a story is safe to post when it is not, which is worse than not having the feature. Unseen is marked rather than styled away — a strip where read and unread look alike is a strip nobody returns to. Stories are read back on every poll rather than only when a thread was touched, because a story files itself outside the thread map and would otherwise not appear until something else arrived. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
4728928d98
commit
acee4597d5
@@ -23,6 +23,8 @@ import io.flamenet.e2e.Vault
|
||||
* That costs a write per message and buys the difference between an app that
|
||||
* survives being killed and one that has to be reinstalled.
|
||||
*/
|
||||
private const val STORY_LIFETIME_SECONDS = 24.0 * 60 * 60
|
||||
|
||||
class Conversations(
|
||||
private val identity: DeviceIdentity,
|
||||
private val vault: Vault,
|
||||
@@ -156,6 +158,48 @@ class Conversations(
|
||||
.copy(attachment = ref)
|
||||
}
|
||||
|
||||
/**
|
||||
* Post a story to several people at once (§6.6).
|
||||
*
|
||||
* ⚠️ **Sealed and uploaded ONCE, then fanned out.** One blob, one key, one
|
||||
* upload — and an ordinary story envelope down each recipient's existing
|
||||
* ratchet. Uploading per recipient would multiply the bytes by the size of
|
||||
* the contact list for no gain, and would hand the relay a set of distinct
|
||||
* blobs it could correlate by size and timing.
|
||||
*
|
||||
* ⚠️ Partial delivery is normal and reported rather than thrown. A contact
|
||||
* with no registered device cannot receive anything, story or not, and
|
||||
* failing the whole post because one person is unreachable would make
|
||||
* posting impossible for anybody with a stale contact.
|
||||
*/
|
||||
fun postStory(
|
||||
data: ByteArray,
|
||||
mime: String,
|
||||
width: Int,
|
||||
height: Int,
|
||||
caption: String?,
|
||||
toUserIds: List<Long>,
|
||||
lifetimeSeconds: Double = STORY_LIFETIME_SECONDS,
|
||||
): StoryPosted {
|
||||
val sealed = Attachment.seal(data)
|
||||
val id = relay.uploadAttachment(sealed.blob)
|
||||
val ref = Attachment.Ref(
|
||||
kind = Attachment.STORY, id = id, key = sealed.key, digest = sealed.digest,
|
||||
bytes = sealed.blob.size, mime = mime, width = width, height = height,
|
||||
expires = System.currentTimeMillis() / 1000.0 + lifetimeSeconds,
|
||||
)
|
||||
val body = Content.attachment(ref, caption, null)
|
||||
|
||||
var reached = 0
|
||||
var missed = 0
|
||||
for (userId in toUserIds) {
|
||||
if (sendPlaintext(userId, body).reachedSomeone) reached++ else missed++
|
||||
}
|
||||
return StoryPosted(ref, reached, missed)
|
||||
}
|
||||
|
||||
data class StoryPosted(val ref: Attachment.Ref, val reached: Int, val missed: Int)
|
||||
|
||||
/** Download and open a blob a message referred to. */
|
||||
fun fetchAttachment(ref: Attachment.Ref): ByteArray =
|
||||
Attachment.open(relay.downloadAttachment(ref.id), ref)
|
||||
|
||||
Reference in New Issue
Block a user