Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions apps/flipcash/core/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -1003,6 +1003,11 @@
<string name="label_chat_media_unavailable">This photo can’t be displayed</string>
<string name="label_chat_preview_sentMessage">You: %1$s</string>
<string name="label_chat_preview_senderMessage">%1$s: %2$s</string>
<string name="label_chat_system_joined">%1$s joined the group</string>
<string name="label_chat_system_joinedSelf">You joined the group</string>
<string name="label_chat_system_added">%1$s added %2$s</string>
<string name="label_chat_system_addedSelf">%1$s added you</string>
<string name="label_chat_system_selfAdded">You added %1$s</string>
<string name="label_chat_preview_sentCash_bySender">%1$s sent %2$s</string>
<string name="label_chat_preview_tippedCash_bySender">%1$s tipped %2$s</string>
<string name="label_chat_preview_sharedProfile">Shared a profile</string>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1379,7 +1379,15 @@ internal class ChatViewModel @AssistedInject constructor(
// that and becomes its own kind of row.
if (content is MessageContent.System) {
return@flatMapIndexed listOfNotNull(
systemMessageItem(message.messageId, index, message.timestamp, content),
systemMessageItem(
messageId = message.messageId,
contentIndex = index,
timestamp = message.timestamp,
content = content,
resources = resources,
selfId = userManager.accountId,
nameOf = { userId -> profiles.systemUserName(userId) },
),
)
}

Expand Down Expand Up @@ -1603,6 +1611,22 @@ internal class ChatViewModel @AssistedInject constructor(
}
}

/**
* A user a system notice names, as [quoteAuthorName] names a quote's author: asked for when the
* roster can't name them, and null until then, so the row reads as its fallback text rather
* than a placeholder. Null for a DM, which has no [senderProfiles].
*/
private fun Map<String, UserProfile>?.systemUserName(userId: ID): String? {
val profiles = this ?: return null
val profile = profiles[userId.hexEncodedString()]
if (profile == null || profile.displayName.isBlank()) {
chatCoordinator.requestSenderProfile(userId)
}
return profile?.let {
nameOrHandle(it.displayName, it.handle) ?: resources.getString(R.string.title_unnamedUser)
}
}

private fun MessageContent.Media.toPhotoSnippet(message: ChatMessage) = ChatQuoteSnippet.Photo(
caption = caption?.text?.takeIf { it.isNotBlank() },
rendition = items.firstOrNull()?.renditions?.firstOrNull(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,19 +3,25 @@ package com.flipcash.app.messenger.internal
import com.flipcash.services.models.chat.MessageContent
import com.flipcash.shared.chat.models.ChatListItem
import com.flipcash.shared.chat.models.displayText
import com.getcode.opencode.model.core.ID
import com.getcode.util.resources.ResourceHelper
import kotlin.time.Instant

/**
* The transcript row for one System content of a message, or `null` when it has no text to draw.
* The null case is a notice stored before the mapper dropped blank ones; it produces no row at all.
* [nameOf] names the users the notice is about; see [displayText].
*/
internal fun systemMessageItem(
messageId: Long,
contentIndex: Int,
timestamp: Instant,
content: MessageContent.System,
resources: ResourceHelper,
selfId: ID?,
nameOf: (ID) -> String?,
): ChatListItem.SystemMessage? {
val text = content.displayText() ?: return null
val text = content.displayText(resources, selfId, nameOf) ?: return null
return ChatListItem.SystemMessage(
messageId = messageId,
contentIndex = contentIndex,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ package com.flipcash.app.messenger.internal

import com.flipcash.services.models.chat.MessageContent
import com.flipcash.services.models.chat.isSystemBlank
import com.getcode.util.resources.ResourceHelper
import io.mockk.mockk
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
Expand All @@ -15,8 +17,11 @@ class SystemMessageItemTest {

private val at = Instant.fromEpochMilliseconds(1_700_000_000_000)

// A notice with no event never reaches a string resource.
private val NoNames = mockk<ResourceHelper>()

private fun item(text: String, id: Long = 7, index: Int = 0) =
systemMessageItem(id, index, at, MessageContent.System(text))
systemMessageItem(id, index, at, MessageContent.System(text), NoNames, selfId = null) { null }

@Test
fun `a notice becomes exactly one row with its own text`() {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,16 +1,61 @@
package com.flipcash.shared.chat.models

import com.flipcash.core.R
import com.flipcash.services.models.chat.MessageContent
import com.flipcash.services.models.chat.isSystemBlank
import com.getcode.opencode.model.core.ID
import com.getcode.util.resources.ResourceHelper

/**
* The text a system notice draws and previews as, or `null` when it has none.
*
* The one place the notice's wording is chosen. When the server sends a structured event this
* client can localise, it becomes `when (event) { known -> localised; else -> fallbackText }` here,
* with a resource lookup as a parameter, and no caller changes.
* The one place the notice's wording is chosen. A [MessageContent.System.event] this client knows
* is worded here, naming its users through [nameOf] and the viewer ([selfId]) as "you". When there
* is no event, or [nameOf] can't name someone it needs yet, the notice reads as its fallback text.
*
* Blank is [isSystemBlank], the definition iOS shares. Text that is not blank is returned as it
* arrived: not trimmed. Rows stored before the mapper dropped blank notices still reach this.
* Blank is [isSystemBlank], the definition iOS shares. Fallback text that is not blank is returned
* as it arrived: not trimmed. Rows stored before the mapper dropped blank notices still reach this.
*/
fun MessageContent.System.displayText(): String? = fallbackText.takeUnless { it.isSystemBlank() }
fun MessageContent.System.displayText(
resources: ResourceHelper,
selfId: ID?,
nameOf: (ID) -> String?,
): String? = event?.worded(resources, selfId, nameOf)
?: fallbackText.takeUnless { it.isSystemBlank() }

private fun MessageContent.SystemEvent.worded(
resources: ResourceHelper,
selfId: ID?,
nameOf: (ID) -> String?,
): String? = when (this) {
is MessageContent.SystemEvent.UserJoined -> {
val joinedIsSelf = selfId != null && userId == selfId
val admitter = admittedBy?.takeUnless { it == userId }
when {
admitter == null -> if (joinedIsSelf) {
resources.getString(R.string.label_chat_system_joinedSelf)
} else {
nameOf(userId)?.let { resources.getString(R.string.label_chat_system_joined, it) }
}
selfId != null && admitter == selfId ->
nameOf(userId)?.let { resources.getString(R.string.label_chat_system_selfAdded, it) }
joinedIsSelf ->
nameOf(admitter)?.let { resources.getString(R.string.label_chat_system_addedSelf, it) }
else -> {
val admitterName = nameOf(admitter)
val joinedName = nameOf(userId)
if (admitterName != null && joinedName != null) {
resources.getString(R.string.label_chat_system_added, admitterName, joinedName)
} else {
null
}
}
}
}
}

/** The users [MessageContent.System.event] names, apart from the viewer: the ones [displayText] looks up. */
fun MessageContent.System.namedUsers(selfId: ID?): List<ID> = when (val event = event) {
is MessageContent.SystemEvent.UserJoined -> listOfNotNull(event.userId, event.admittedBy)
null -> emptyList()
}.distinct().filterNot { selfId != null && it == selfId }
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import com.flipcash.services.models.chat.MessageContent
import com.flipcash.services.models.chat.WidgetContent
import com.flipcash.services.models.handle
import com.flipcash.shared.chat.models.displayText
import com.flipcash.shared.chat.models.namedUsers
import com.flipcash.shared.chat.ChatSummary
import com.flipcash.shared.chat.media.ChatMediaText
import com.getcode.opencode.model.core.ID
Expand Down Expand Up @@ -67,6 +68,11 @@ fun ChatSummary.toConversationReference(
*/
fun ChatSummary.unnamedGroupSender(selfId: ID?, senderProfiles: Map<String, UserProfile>): ID? {
if (metadata.type != ChatType.GROUP) return null
// A notice has no sender; the users it names are what its preview needs. One per call is
// enough: the next is asked for when the list re-emits with the first named.
(metadata.lastMessage?.content?.firstOrNull() as? MessageContent.System)?.let { notice ->
return notice.namedUsers(selfId).firstOrNull { groupSenderName(it, senderProfiles) == null }
}
val senderId = metadata.lastMessage?.senderId ?: return null
if (senderId == selfId) return null
return senderId.takeIf { groupSenderName(senderId, senderProfiles) == null }
Expand Down Expand Up @@ -100,7 +106,9 @@ private fun ChatSummary.formatPreview(
null
}
return lastMsg.content.firstOrNull()
?.previewText(sentBySelf, senderName, isGroup, tokensByMint, resources)
?.previewText(sentBySelf, senderName, isGroup, tokensByMint, resources) { content ->
content.displayText(resources, selfId) { groupSenderName(it, senderProfiles) }
}
}

/**
Expand All @@ -116,6 +124,7 @@ private fun MessageContent.previewText(
tokensByMint: Map<Mint, Token>,
resources: ResourceHelper,
depth: Int = 0,
systemText: (MessageContent.System) -> String?,
): String? = when (this) {
is MessageContent.Text -> {
val message = text.takeIf { it.isNotEmpty() }
Expand Down Expand Up @@ -173,7 +182,7 @@ private fun MessageContent.previewText(
null
} else {
content.firstOrNull()
?.previewText(sentBySelf, senderName, isGroup, tokensByMint, resources, depth + 1)
?.previewText(sentBySelf, senderName, isGroup, tokensByMint, resources, depth + 1, systemText)
}

// The feed carries the newest message that still has content, so a tombstone only
Expand All @@ -193,7 +202,7 @@ private fun MessageContent.previewText(
}
}
// The notice's own words, with no You: prefix and no sender name; it has no sender.
is MessageContent.System -> displayText()
is MessageContent.System -> systemText(this)

// A widget this client can't draw previews as nothing, as an undecryptable message does.
is MessageContent.Widget -> when (widget) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
package com.flipcash.shared.chat.models

import com.flipcash.core.R
import com.flipcash.services.models.chat.MessageContent
import com.flipcash.services.models.chat.MessageContent.SystemEvent.UserJoined
import com.getcode.opencode.model.core.ID
import com.getcode.util.resources.ResourceHelper
import io.mockk.every
import io.mockk.mockk
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull

/** How a join notice names its users, and when it falls back to the server's text. */
class SystemMessageTextTest {

private val self: ID = listOf(1)
private val alex: ID = listOf(2)
private val sam: ID = listOf(3)
private val names = mapOf(alex to "Alex", sam to "Sam")

private val templates = mapOf(
R.string.label_chat_system_joined to "%1\$s joined the group",
R.string.label_chat_system_joinedSelf to "You joined the group",
R.string.label_chat_system_added to "%1\$s added %2\$s",
R.string.label_chat_system_addedSelf to "%1\$s added you",
R.string.label_chat_system_selfAdded to "You added %1\$s",
)

private val resources = mockk<ResourceHelper> {
every { getString(any()) } answers { templates.getValue(firstArg()) }
every { getString(any(), *anyVararg()) } answers {
templates.getValue(firstArg()).format(*(args[1] as Array<*>))
}
}

private fun text(
joined: ID,
admittedBy: ID? = null,
nameOf: (ID) -> String? = names::get,
fallback: String = "Someone joined",
) = MessageContent.System(fallback, UserJoined(joined, admittedBy))
.displayText(resources, self, nameOf)

@Test
fun `a direct join names who joined`() {
assertEquals("Alex joined the group", text(alex))
}

@Test
fun `the viewer joining reads as you`() {
assertEquals("You joined the group", text(self))
}

@Test
fun `an admitted join names both users`() {
assertEquals("Sam added Alex", text(alex, admittedBy = sam))
}

@Test
fun `the viewer admitted reads as you`() {
assertEquals("Sam added you", text(self, admittedBy = sam))
}

@Test
fun `the viewer admitting reads as you`() {
assertEquals("You added Alex", text(alex, admittedBy = self))
}

@Test
fun `admitting yourself is a plain join`() {
assertEquals("Alex joined the group", text(alex, admittedBy = alex))
assertEquals("You joined the group", text(self, admittedBy = self))
}

@Test
fun `a name not known yet falls back to the server's text`() {
assertEquals("Someone joined", text(alex, nameOf = { null }))
assertEquals("Someone joined", text(alex, admittedBy = sam, nameOf = { if (it == sam) "Sam" else null }))
assertEquals("Someone joined", text(alex, admittedBy = sam, nameOf = { if (it == alex) "Alex" else null }))
}

@Test
fun `a notice with no event reads as its fallback text`() {
assertEquals("Sam left", MessageContent.System("Sam left").displayText(resources, self) { null })
assertNull(MessageContent.System(" ").displayText(resources, self) { null })
}

@Test
fun `the users to look up leave out the viewer`() {
assertEquals(listOf(alex, sam), MessageContent.System("x", UserJoined(alex, sam)).namedUsers(self))
assertEquals(listOf(sam), MessageContent.System("x", UserJoined(self, sam)).namedUsers(self))
assertEquals(listOf(alex), MessageContent.System("x", UserJoined(alex, alex)).namedUsers(self))
assertEquals(emptyList(), MessageContent.System("x").namedUsers(self))
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -395,4 +395,25 @@ class ChatSummaryPreviewTest {
fun `a notice with blank text has no preview`() {
assertNull(groupReference(listOf(MessageContent.System(" \u0085")), senderId = null).lastMessagePreview)
}

@Test
fun `a join notice names who joined from the roster`() {
every { resources.getString(R.string.label_chat_system_joined, "Alice") } returns "Alice joined the group"
val notice = MessageContent.System("Someone joined", MessageContent.SystemEvent.UserJoined(other, admittedBy = null))
assertEquals("Alice joined the group", groupReference(listOf(notice), senderId = null).lastMessagePreview)
}

@Test
fun `a join notice naming someone unknown previews as its fallback and asks for them`() {
val stranger: ID = listOf(5)
val notice = MessageContent.System("Someone joined", MessageContent.SystemEvent.UserJoined(stranger, admittedBy = null))
assertEquals("Someone joined", groupReference(listOf(notice), senderId = null).lastMessagePreview)
assertEquals(stranger, groupSummary(listOf(notice), senderId = null).unnamedGroupSender(self, emptyMap()))
}

@Test
fun `a join notice whose users are all named asks for no one`() {
val notice = MessageContent.System("Someone joined", MessageContent.SystemEvent.UserJoined(self, admittedBy = other))
assertNull(groupSummary(listOf(notice), senderId = null).unnamedGroupSender(self, emptyMap()))
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -235,9 +235,16 @@ sealed interface MessageContentSerialized {
@SerialName("widget_share_profile")
data class WidgetShareProfile(val username: String?) : MessageContentSerialized

// Mirrors MessageContent.System. [joinedUser] is set for a UserJoined event, and
// [admittedBy] when someone let them in; both hex-encoded. Rows stored before the event was
// kept have neither and decode as fallback-only.
@Serializable
@SerialName("system")
data class System(val fallbackText: String) : MessageContentSerialized
data class System(
val fallbackText: String,
val joinedUser: String? = null,
val admittedBy: String? = null,
) : MessageContentSerialized

// Mirrors MessageContent.Unsupported: a content type the build that stored it didn't know.
@Serializable
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -411,7 +411,14 @@ private fun MessageContent.toSerialized(): MessageContentSerialized = when (this
is MessageContent.Widget -> MessageContentSerialized.WidgetShareProfile(
username = (widget as? WidgetContent.ShareProfile)?.username,
)
is MessageContent.System -> MessageContentSerialized.System(fallbackText = fallbackText)
is MessageContent.System -> when (val event = event) {
is MessageContent.SystemEvent.UserJoined -> MessageContentSerialized.System(
fallbackText = fallbackText,
joinedUser = event.userId.hexEncodedString(),
admittedBy = event.admittedBy?.hexEncodedString(),
)
null -> MessageContentSerialized.System(fallbackText = fallbackText)
}
MessageContent.Unsupported -> MessageContentSerialized.Unsupported
is MessageContent.Deleted -> MessageContentSerialized.Deleted(
deletedAt = deletedTs.epochSeconds,
Expand Down Expand Up @@ -449,7 +456,10 @@ private fun MessageContentSerialized.toDomain(): MessageContent = when (this) {
items = items,
caption = caption?.let { MessageContent.Text(it.text) },
)
is MessageContentSerialized.System -> MessageContent.System(fallbackText = fallbackText)
is MessageContentSerialized.System -> MessageContent.System(
fallbackText = fallbackText,
event = joinedUser?.let { MessageContent.SystemEvent.UserJoined(it.hexToIdExt(), admittedBy?.hexToIdExt()) },
)
MessageContentSerialized.Unsupported -> MessageContent.Unsupported
is MessageContentSerialized.Deleted -> MessageContent.Deleted(
deletedTs = Instant.fromEpochSeconds(deletedAt),
Expand Down
Loading
Loading