Merge branch 'feat/pin-a-message' into 'main'
Recognise a pinned message (§6.19) See merge request paulhitt/flamenet-messenger-android!19
This commit was merged in pull request #46.
This commit is contained in:
@@ -103,6 +103,8 @@ From `E2E/E2EInbox.swift`, which is the real feature list.
|
||||
||||||| parent of fa9fdf4 (Search inside messages, across every conversation)
|
||||
| Reactions | 🟡 codec done, no UI |
|
||||
| Search inside messages | ✅ scanned, never indexed — no plaintext index at rest |
|
||||
||||||| parent of 9d33e60 (Recognise a pinned message (§6.19))
|
||||
| Pinned message (§6.19) | 🟡 codec done, in progress — an inbound pin is recognised and not drawn as an empty bubble |
|
||||
| Photos | ✅ send via the system picker, render inline |
|
||||
| Voice notes (record + play) | ✅ AAC/MP4, tap to record, tap to send |
|
||||
| Stories, and seen-state | ✅ 24h, sent per-contact, expired bytes deleted |
|
||||
|
||||
@@ -79,6 +79,14 @@ data class Content(
|
||||
* off, which is the rule the whole section rests on.
|
||||
*/
|
||||
val mentions: List<Long> = emptyList(),
|
||||
|
||||
/**
|
||||
* Set iff this envelope pins or unpins a message (§6.19): the id being
|
||||
* pinned, or the empty string to take the pin down.
|
||||
*
|
||||
* 🔴 An id and never a copy of the message.
|
||||
*/
|
||||
val pins: String? = null,
|
||||
) {
|
||||
|
||||
/**
|
||||
@@ -160,7 +168,7 @@ data class Content(
|
||||
* arriving as a duplicate, which is worse than the typo.
|
||||
*/
|
||||
val isRenderable: Boolean
|
||||
get() = reaction == null && call == null && editOf == null && !isControl
|
||||
get() = reaction == null && call == null && pins == null && editOf == null && !isControl
|
||||
|
||||
/**
|
||||
* Control traffic: consumed by a layer, never shown.
|
||||
@@ -318,6 +326,15 @@ data class Content(
|
||||
*/
|
||||
const val EDIT = "edit"
|
||||
|
||||
/**
|
||||
* A pinned message, or a pin being taken down (§6.19).
|
||||
*
|
||||
* 🔴 **Not in [CONTROL].** Control kinds are swallowed whole; this one
|
||||
* has to be acted on, and a build that merely swallowed it would drop
|
||||
* every pin while looking like it worked.
|
||||
*/
|
||||
const val PIN = "pin"
|
||||
|
||||
/**
|
||||
* ⚠️ Consumed, never shown. Each of these is a real message the web
|
||||
* client sends today, and rendering one puts raw JSON in somebody's
|
||||
@@ -334,7 +351,7 @@ data class Content(
|
||||
* draw (shown as a placeholder) — never raw JSON in a bubble.
|
||||
*/
|
||||
val KNOWN = CONTROL + setOf(
|
||||
TEXT, REACT, STICKER, EDIT,
|
||||
TEXT, REACT, STICKER, PIN, EDIT,
|
||||
Attachment.IMAGE, Attachment.AUDIO, Attachment.STORY,
|
||||
CallSignal.OFFER, CallSignal.ANSWER, CallSignal.END,
|
||||
)
|
||||
@@ -449,6 +466,21 @@ data class Content(
|
||||
assemble(Kind.EDIT, caption = newText, groupId = groupId,
|
||||
about = Reference(of, author = 0, preview = ""))
|
||||
|
||||
/**
|
||||
* A pin, or a pin being taken down (§6.19).
|
||||
*
|
||||
* ⚠️ An empty [messageId] unpins. Removal has to be a message rather
|
||||
* than an absence: the other device has already drawn the pin, and
|
||||
* there is no other way to reach it.
|
||||
*
|
||||
* 🔴 An id and never a copy of the message. A pin carrying the text
|
||||
* would keep a message readable after its own timer had removed it, so
|
||||
* pinning would quietly defeat disappearing messages.
|
||||
*/
|
||||
fun pin(messageId: String, groupId: String? = null): String =
|
||||
assemble(Kind.PIN, groupId = groupId,
|
||||
about = Reference(messageId, author = 0, preview = ""))
|
||||
|
||||
fun callSignal(signal: CallSignal): String {
|
||||
val call = JSONObject().put("id", signal.callId)
|
||||
signal.sdp?.let { call.put("sdp", it) }
|
||||
@@ -654,6 +686,14 @@ data class Content(
|
||||
about = about, editOf = about.messageId)
|
||||
}
|
||||
|
||||
// §6.19 — a pin changes a banner and is never a bubble. Checked
|
||||
// before the control swallow, which would consume it correctly and
|
||||
// act on nothing.
|
||||
if (kind == Kind.PIN && about != null) {
|
||||
return Content("", kind = kind, raw = json, groupId = groupId,
|
||||
about = about, pins = about.messageId)
|
||||
}
|
||||
|
||||
// Control traffic. Returned with its envelope so the caller can act
|
||||
// on it, and never renderable.
|
||||
//
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
package io.flamenet.e2e
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* Pinning a message in a conversation (§6.19).
|
||||
*
|
||||
* ⚠️ **The codec half.** The Android app does not pin yet, but it must already
|
||||
* understand an envelope the iPhone app sends — and in particular must not draw
|
||||
* one as a message, which is what an unrecognised pin becomes.
|
||||
*
|
||||
* 🔴 A pin is an id and never a copy of the message. A pin carrying the text
|
||||
* would keep a message readable after its own timer had removed it, so pinning
|
||||
* would quietly defeat disappearing messages.
|
||||
*/
|
||||
class PinTest {
|
||||
|
||||
@Test
|
||||
fun `a pin names the message it pins`() {
|
||||
val content = Content.parse(Content.pin("abc"))
|
||||
assertEquals("abc", content.pins)
|
||||
assertEquals(Content.Kind.PIN, content.kind)
|
||||
}
|
||||
|
||||
/** An empty id takes the pin down. */
|
||||
@Test
|
||||
fun `an unpin round trips`() {
|
||||
assertEquals("", Content.parse(Content.pin("")).pins)
|
||||
}
|
||||
|
||||
/**
|
||||
* 🔴 The bug this prevents. An unrecognised pin falls through `parse` to
|
||||
* the attachment path and comes back renderable — an empty bubble in
|
||||
* somebody's conversation every time the other side pins something.
|
||||
*/
|
||||
@Test
|
||||
fun `a pin is not a message`() {
|
||||
val content = Content.parse(Content.pin("abc"))
|
||||
assertFalse(content.isRenderable, "a pin would be drawn as a bubble")
|
||||
assertTrue(content.text.isEmpty())
|
||||
assertNull(content.attachment)
|
||||
}
|
||||
|
||||
/**
|
||||
* ⚠️ Not swallowed as generic control either. A build that consumed it
|
||||
* silently would drop every pin while looking like it worked.
|
||||
*/
|
||||
@Test
|
||||
fun `a pin is a known kind, not unknown control`() {
|
||||
assertTrue(Content.Kind.PIN in Content.Kind.KNOWN)
|
||||
assertTrue(Content.Kind.PIN !in Content.Kind.CONTROL,
|
||||
"a pin swallowed as control would drop silently")
|
||||
}
|
||||
|
||||
/** 🔴 The envelope carries no copy of the message — only its id. */
|
||||
@Test
|
||||
fun `the envelope carries no copy of the message`() {
|
||||
val wire = Content.pin("abc")
|
||||
assertFalse(wire.contains("caption"), "a pin carried message text: $wire")
|
||||
assertEquals("", Content.parse(wire).about?.preview)
|
||||
}
|
||||
|
||||
/** Group routing survives, for when a group transcript can name a message. */
|
||||
@Test
|
||||
fun `a group pin keeps its routing`() {
|
||||
val content = Content.parse(Content.pin("abc", groupId = "g1"))
|
||||
assertEquals("g1", content.groupId)
|
||||
assertEquals("abc", content.pins)
|
||||
}
|
||||
|
||||
/** ⚠️ `fnc` first, like every other envelope. */
|
||||
@Test
|
||||
fun `the envelope starts with the marker`() {
|
||||
assertTrue(Content.pin("abc").startsWith("{\"fnc\":1,"))
|
||||
}
|
||||
|
||||
/** A pin with no target is not a pin. */
|
||||
@Test
|
||||
fun `a pin with no reference is not treated as one`() {
|
||||
assertNull(Content.parse("""{"fnc":1,"kind":"pin"}""").pins)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user