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
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package com.flipcash.app.core.blocklist

import com.flipcash.services.models.chat.MediaItem
import com.flipcash.services.models.nameOrHandle
import com.getcode.opencode.model.core.ID
import kotlin.time.Instant

Expand All @@ -11,12 +12,22 @@ import kotlin.time.Instant
*
* @param userId The blocked user
* @param displayName Resolved display name, or empty if the profile could not be resolved
* @param handle Resolved public `@handle`, or null if unclaimed/unresolved
* @param profilePicture Resolved avatar, or null if unset/unresolved
* @param blockedAt When the user was blocked
*/
data class BlockedUserProfile(
val userId: ID,
val displayName: String,
val handle: String?,
val profilePicture: MediaItem?,
val blockedAt: Instant,
)
) {
/**
* What to call this person: [displayName] when they have one, [handle] when they don't.
*
* Blocking is reachable from a tip DM, so a blocked account need never have had a name — the
* same rule the chat surfaces use ([com.flipcash.app.core.chat.ChatParticipant.name]).
*/
val name: String? get() = nameOrHandle(displayName, handle)
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import android.os.Parcelable
import com.flipcash.app.core.contacts.DeviceContact
import com.flipcash.services.models.UserProfile
import com.flipcash.services.models.handle
import com.flipcash.services.models.nameOrHandle
import com.getcode.opencode.model.core.ID
import kotlinx.parcelize.Parcelize

Expand Down Expand Up @@ -31,6 +32,15 @@ sealed interface ChatParticipant: Parcelable {
*/
val handle: String?

/**
* What to call this person — the one rule every surface that names them uses.
*
* [displayName] when they have one, [handle] when they don't. Null only when they have
* neither, which for a [TipUser] means a profile the server sent us nothing identifying for,
* and for a [Contact] means a device contact with an empty name.
*/
val name: String? get() = nameOrHandle(displayName, handle)

data class Contact(val contact: DeviceContact) : ChatParticipant {
override val displayName: String get() = contact.displayName
override val handle: String? get() = null
Expand Down
1 change: 1 addition & 0 deletions apps/flipcash/features/messenger/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -28,4 +28,5 @@ dependencies {

testImplementation(libs.bundles.unit.testing)
testImplementation(libs.mockito.kotlin)
testImplementation(libs.robolectric)
}
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,9 @@ internal fun ChatTopBar(

Text(
modifier = Modifier.weight(1f),
text = state.participant?.displayName.orEmpty(),
// Name-or-handle: the bar is one line (node 9443:9094), and the handle is
// the only identity a name-less tip DM counterparty has.
text = state.participant?.name.orEmpty(),
style = CodeTheme.typography.textMedium,
color = CodeTheme.colors.textMain,
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ internal fun ContactInfoContainer(
) {
Text(
modifier = if (onOpenProfile != null) Modifier.weight(1f, fill = false) else Modifier,
text = participant?.displayName.orEmpty(),
text = participant?.name.orEmpty(),
autoSize = TextAutoSize.StepBased(
minFontSize = CodeTheme.typography.textSmall.fontSize,
maxFontSize = CodeTheme.typography.textLarge.fontSize,
Expand All @@ -103,8 +103,9 @@ internal fun ContactInfoContainer(

// The line under the name says how this person is addressed: a tip DM by their public
// handle (node 9443:8928), a contact DM by the number the chat is keyed on. Never both —
// only one of the two identity sources backs any given conversation.
val handle = participant?.handle
// only one of the two identity sources backs any given conversation. Dropped when the name
// above already *is* the handle, so a name-less account doesn't show it twice.
val handle = participant?.handle?.takeIf { it != participant.name }
if (handle != null) {
Text(
modifier = Modifier.padding(top = CodeTheme.dimens.grid.x1),
Expand Down Expand Up @@ -277,6 +278,13 @@ private fun Preview_AllStates() {
),
)

// A tip DM's counterparty who never set a name: the handle is their whole identity, so it
// takes the name line and the line beneath it is dropped.
val handleOnlyUser = ChatParticipant.TipUser(
userId = listOf(2.toByte()),
profile = UserProfile.Empty.copy(username = "sally_streamer"),
)

// Fixed width so every state renders at the same size regardless of name/number length.
val cardWidth = Modifier.width(300.dp)
Column(
Expand All @@ -286,6 +294,7 @@ private fun Preview_AllStates() {
ContactInfoContainer(participant = knownContact, modifier = cardWidth)
ContactInfoContainer(participant = unknownContact, modifier = cardWidth)
ContactInfoContainer(participant = tipUser, modifier = cardWidth)
ContactInfoContainer(participant = handleOnlyUser, modifier = cardWidth)
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package com.flipcash.app.messenger.internal.screens.profile

import android.os.Parcelable
import androidx.annotation.VisibleForTesting
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
Expand Down Expand Up @@ -79,8 +80,9 @@ internal fun ChatProfileScreen(viewModel: ChatProfileViewModel) {
}
}

@VisibleForTesting
@Composable
private fun ProfileHeader(
internal fun ProfileHeader(
participant: ChatParticipant?,
joinDate: Instant?,
modifier: Modifier = Modifier,
Expand All @@ -97,12 +99,25 @@ private fun ProfileHeader(
)
Text(
modifier = Modifier.padding(top = CodeTheme.dimens.grid.x2),
text = participant?.displayName.orEmpty(),
text = participant?.name.orEmpty(),
style = CodeTheme.typography.textLarge,
color = CodeTheme.colors.textMain,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
// The handle sits under the name, the same shape as the info card's identity line
// (node 9443:8928). Left out when the line above is already the handle, so a name-less
// account doesn't read it twice.
participant?.handle?.takeIf { it != participant.name }?.let { handle ->
Text(
modifier = Modifier.padding(top = CodeTheme.dimens.grid.x1),
text = handle,
style = CodeTheme.typography.textSmall,
color = CodeTheme.colors.textSecondary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
joinDate?.let { instant ->
Text(
modifier = Modifier.padding(top = CodeTheme.dimens.grid.x1),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,11 @@ internal class ChatProfileViewModel @Inject constructor(
.filterIsInstance<ChatParticipant.TipUser>()
.onEach { participant ->
BottomBarManager.showAlert(
title = resources.getString(R.string.prompt_title_blockUser, participant.displayName),
// "Block ?" is what this read for an account with no display name.
title = resources.getString(
R.string.prompt_title_blockUser,
participant.name.orEmpty(),
),
message = resources.getString(R.string.prompt_description_blockUser),
actions = listOf(
BottomBarAction(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
package com.flipcash.app.messenger.internal

import android.graphics.Bitmap
import android.graphics.Canvas
import android.view.View
import androidx.activity.ComponentActivity
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.ui.Modifier
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.unit.dp
import com.flipcash.app.core.chat.ChatParticipant
import com.flipcash.app.core.contacts.DeviceContact
import com.flipcash.app.messenger.internal.screens.components.ChatTopBar
import com.flipcash.app.messenger.internal.screens.components.ContactInfoContainer
import com.flipcash.app.messenger.internal.screens.profile.ProfileHeader
import com.flipcash.app.theme.FlipcashPreview
import com.flipcash.services.models.UserProfile
import com.flipcash.services.models.chat.ChatType
import com.getcode.navigation.core.CodeNavigator
import io.mockk.mockk
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode
import java.io.File
import kotlin.time.Instant

/**
* Renders the DM info card in each identity state to a PNG so the name-or-handle rule can be
* eyeballed without an emulator. Not an assertion test — it writes to `build/screenshots/`.
*
* Same mechanics as `TokenCardWatermarkScreenshotTest`: pause the clock, pump a fixed number of
* frames, and draw the Android view directly, so a composable that keeps scheduling frames can't
* hang `captureToImage()`'s implicit `waitForIdle`.
*/
@RunWith(RobolectricTestRunner::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE)
@Config(sdk = [34], qualifiers = "w400dp-h1100dp-xhdpi")
class ChatIdentityScreenshotTest {

@get:Rule
val composeRule = createAndroidComposeRule<ComponentActivity>()

// A saved device contact: name + number, handle line never applies.
private val knownContact = ChatParticipant.Contact(
DeviceContact(
e164 = "+15551234567",
androidContactId = 1L,
displayName = "Ada Lovelace",
photoUri = null,
displayNumber = "(555) 123-4567",
)
)

// A tip DM counterparty with both: name on top, handle underneath.
private val namedTipUser = ChatParticipant.TipUser(
userId = listOf(1.toByte()),
profile = UserProfile.Empty.copy(
displayName = "Grace Hopper",
username = "grace_hopper",
),
)

// A tip DM counterparty who never set a name — the case this change fixes. Before, every
// surface below rendered them as an empty string.
private val handleOnlyTipUser = ChatParticipant.TipUser(
userId = listOf(2.toByte()),
profile = UserProfile.Empty.copy(username = "sally_streamer"),
)

@Test
fun rendersInfoCardIdentityStates() {
composeRule.mainClock.autoAdvance = false
composeRule.setContent {
FlipcashPreview(showBackground = true) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
val cardWidth = Modifier.width(300.dp)
ContactInfoContainer(participant = knownContact, modifier = cardWidth)
ContactInfoContainer(participant = namedTipUser, modifier = cardWidth)
ContactInfoContainer(participant = handleOnlyTipUser, modifier = cardWidth)
}
}
}
repeat(10) { composeRule.mainClock.advanceTimeByFrame() }

capture("chat_info_card_identity.png")
}

@Test
fun rendersTopBarIdentityStates() {
val navigator = mockk<CodeNavigator>(relaxed = true)
composeRule.mainClock.autoAdvance = false
composeRule.setContent {
FlipcashPreview(showBackground = true) {
Column(
modifier = Modifier.width(360.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
listOf(knownContact, namedTipUser, handleOnlyTipUser).forEach { participant ->
ChatTopBar(
navigator = navigator,
state = ChatViewModel.State(
participant = participant,
chatType = if (participant is ChatParticipant.TipUser) {
ChatType.TIP_DM
} else {
ChatType.CONTACT_DM
},
),
chatActionHandler = {},
)
}
}
}
}
repeat(10) { composeRule.mainClock.advanceTimeByFrame() }

capture("chat_top_bar_identity.png")
}

@Test
fun rendersProfileHeaderIdentityStates() {
val joinDate = Instant.fromEpochMilliseconds(1_700_000_000_000)
composeRule.mainClock.autoAdvance = false
composeRule.setContent {
FlipcashPreview(showBackground = true) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(24.dp),
) {
listOf(knownContact, namedTipUser, handleOnlyTipUser).forEach { participant ->
ProfileHeader(
participant = participant,
joinDate = joinDate,
modifier = Modifier.width(300.dp),
)
}
}
}
}
repeat(10) { composeRule.mainClock.advanceTimeByFrame() }

capture("chat_profile_header_identity.png")
}

private fun capture(name: String) {
val root: View = composeRule.activity.findViewById(android.R.id.content)
val width = root.width.takeIf { it > 0 } ?: 1080
val height = root.height.takeIf { it > 0 } ?: 1920
val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
root.draw(Canvas(bitmap))
val cropped = bitmap.trimmedToDrawnArea()

val outDir = File("build/screenshots").apply { mkdirs() }
val file = File(outDir, name)
file.outputStream().use { cropped.compress(Bitmap.CompressFormat.PNG, 100, it) }
println("SCREENSHOT_WRITTEN: ${file.absolutePath} (${cropped.width}x${cropped.height})")
}

/**
* The content view is the full device, but the previews wrap their content — crop away the
* untouched (fully transparent) margin so the PNG is just what was composed.
*/
private fun Bitmap.trimmedToDrawnArea(): Bitmap {
val pixels = IntArray(width * height)
getPixels(pixels, 0, width, 0, 0, width, height)
var left = width
var top = height
var right = -1
var bottom = -1
for (y in 0 until height) {
for (x in 0 until width) {
if (pixels[y * width + x] ushr 24 == 0) continue
if (x < left) left = x
if (x > right) right = x
if (y < top) top = y
if (y > bottom) bottom = y
}
}
if (right < left || bottom < top) return this
return Bitmap.createBitmap(this, left, top, right - left + 1, bottom - top + 1)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,7 @@ private fun BlockedUserRow(
) {
ContactAvatar(
image = user.profilePicture,
displayName = user.displayName,
displayName = user.name.orEmpty(),
// Blocked users are shown obscured, per the design.
blurred = true,
modifier = Modifier
Expand All @@ -121,7 +121,8 @@ private fun BlockedUserRow(
)
Text(
modifier = Modifier.weight(1f),
text = user.displayName,
// One line, so the handle stands in for a missing name rather than sitting under it.
text = user.name.orEmpty(),
style = CodeTheme.typography.textLarge,
color = CodeTheme.colors.textMain,
maxLines = 1,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ internal class BlocklistViewModel @Inject constructor(
BottomBarManager.showMessage(
title = resources.getString(
R.string.prompt_title_unblockUser,
event.user.displayName,
event.user.name.orEmpty(),
),
message = resources.getString(R.string.prompt_description_unblockUser),
actions = listOf(
Expand Down
Loading
Loading