diff --git a/apps/flipcash/shared/notifications/src/main/kotlin/com/flipcash/app/notifications/MessageBodyPlanner.kt b/apps/flipcash/shared/notifications/src/main/kotlin/com/flipcash/app/notifications/MessageBodyPlanner.kt new file mode 100644 index 0000000000..20781f0b10 --- /dev/null +++ b/apps/flipcash/shared/notifications/src/main/kotlin/com/flipcash/app/notifications/MessageBodyPlanner.kt @@ -0,0 +1,37 @@ +package com.flipcash.app.notifications + +/** + * The text a chat push should show once `MessagingStyle` is drawing the sender itself. + * + * Pure for the same reason [planSenderLookup] is: the rule is what's worth testing, and stating it + * needs neither a database nor a live service. + * + * The server composes a body that names its sender — "Kevin Ricoy: whoa" — because a plain + * notification has nowhere else to put it. Under `MessagingStyle` there is: the platform renders + * the `Person` above the line in a group, and the notification's own title carries it in a DM. The + * name then appears twice. + * + * [senderNames] is every name this sender is known by rather than just the one being displayed, + * because they can disagree — the line is rendered with the address-book name when there is one, + * while the server composed the body from the profile's display name. Only an exact match is + * stripped, so a message that happens to start "Neil: " stays intact unless Neil sent it. + * + * @param body the resolved push body, null when the push carries none + * @param senderNames the sender's known names, in any order; blanks are ignored + */ +fun planMessageBody(body: String?, senderNames: List): String { + val text = body.orEmpty() + val prefix = senderNames + .asSequence() + .filterNot { it.isNullOrBlank() } + .map { "$it:" } + // Longest first, so a sender known by both a name and a fuller version of it loses the + // whole thing rather than leaving the remainder behind. + .sortedByDescending { it.length } + .firstOrNull { text.startsWith(it, ignoreCase = true) } + ?: return text + + // A body that is only the prefix isn't an attribution to strip — dropping it would post an + // empty message. + return text.substring(prefix.length).trimStart().ifBlank { text } +} diff --git a/apps/flipcash/shared/notifications/src/main/kotlin/com/flipcash/app/notifications/NotificationService.kt b/apps/flipcash/shared/notifications/src/main/kotlin/com/flipcash/app/notifications/NotificationService.kt index f128db3721..2ade7603d6 100644 --- a/apps/flipcash/shared/notifications/src/main/kotlin/com/flipcash/app/notifications/NotificationService.kt +++ b/apps/flipcash/shared/notifications/src/main/kotlin/com/flipcash/app/notifications/NotificationService.kt @@ -340,11 +340,16 @@ class NotificationService : FirebaseMessagingService(), } val e164 = contactE164 ?: sender?.profile?.verifiedPhoneNumber - val senderName = e164?.let { contactResolver.resolveName(it) } - ?: sender?.profile?.displayName?.takeIf { it.isNotBlank() } - ?: sender?.profile?.socialHandle() - ?: title - ?: "" + // Every name this sender is known by, not just the one rendered: the server composed the + // body from one of them and the line is drawn with another, so stripping the sender's name + // out of their own message needs all of them. See [planMessageBody]. + val senderNames = listOf( + e164?.let { contactResolver.resolveName(it) }, + sender?.profile?.displayName, + sender?.profile?.socialHandle(), + title, + ) + val senderName = senderNames.firstOrNull { !it.isNullOrBlank() }.orEmpty() // Device-contact photo (local, synchronous) first; otherwise the profile // picture URL loaded through the app's shared Coil loader (cache-first, @@ -397,7 +402,7 @@ class NotificationService : FirebaseMessagingService(), ?.let { NotificationCompat.MessagingStyle.extractMessagingStyleFromNotification(it) } ?: NotificationCompat.MessagingStyle(selfPerson) - style.addMessage(body.orEmpty(), System.currentTimeMillis(), senderPerson) + style.addMessage(planMessageBody(body, senderNames), System.currentTimeMillis(), senderPerson) // After the extract above, not before: a re-post rebuilds the style from the notification // already on screen, which carries the old flag and title back with it. diff --git a/apps/flipcash/shared/notifications/src/test/kotlin/com/flipcash/app/notifications/MessageBodyPlannerTest.kt b/apps/flipcash/shared/notifications/src/test/kotlin/com/flipcash/app/notifications/MessageBodyPlannerTest.kt new file mode 100644 index 0000000000..61486323ed --- /dev/null +++ b/apps/flipcash/shared/notifications/src/test/kotlin/com/flipcash/app/notifications/MessageBodyPlannerTest.kt @@ -0,0 +1,50 @@ +package com.flipcash.app.notifications + +import kotlin.test.Test +import kotlin.test.assertEquals + +/** + * `MessagingStyle` renders the sender itself, so a body that names its own sender shows the name + * twice — which is what the server sends, since a plain notification has nowhere else to put it. + */ +class MessageBodyPlannerTest { + + @Test + fun `the sender's own name is dropped from the front of their message`() { + assertEquals("whoa", planMessageBody("Kevin Ricoy: whoa", listOf("Kevin Ricoy"))) + } + + @Test + fun `a name the sender is known by elsewhere is dropped too`() { + // Rendered under the address-book name; composed by the server from the profile's. + assertEquals( + "Howdy", + planMessageBody("neilbryan: Howdy", listOf("Neil", "neilbryan")), + ) + } + + @Test + fun `the longest matching name is what gets dropped`() { + assertEquals("hi", planMessageBody("Ted Livingston: hi", listOf("Ted", "Ted Livingston"))) + } + + @Test + fun `someone else's name is left alone`() { + assertEquals("Neil: Howdy", planMessageBody("Neil: Howdy", listOf("Kevin Ricoy"))) + } + + @Test + fun `a message that is only the sender's name survives`() { + assertEquals("Kevin Ricoy:", planMessageBody("Kevin Ricoy:", listOf("Kevin Ricoy"))) + } + + @Test + fun `a body with no sender to match is unchanged`() { + assertEquals("whoa", planMessageBody("whoa", listOf(null, "", " "))) + } + + @Test + fun `a push with no body plans an empty message`() { + assertEquals("", planMessageBody(null, listOf("Kevin Ricoy"))) + } +}