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
2 changes: 2 additions & 0 deletions apps/flipcash/core/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -799,6 +799,8 @@

<string name="title_settings">Settings</string>
<string name="title_you">You</string>
<!-- What a person with neither a display name nor a handle is called. Matches iOS's ConversationController.fallbackCounterpartName. -->
<string name="title_unnamedUser">Flipcash User</string>

<string name="title_withdrawUsdfAsUsdc">Withdraw as USDC</string>
<!-- v2 wording of the same screen: the reserve is branded "Dollars" and the chain is named last. -->
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,8 @@ import com.flipcash.services.models.chat.MessageContent
import com.flipcash.services.models.chat.TypingState
import com.flipcash.services.models.chat.ViewerState
import com.flipcash.services.models.chat.isDmAddressable
import com.flipcash.services.models.handle
import com.flipcash.services.models.nameOrHandle
import com.flipcash.services.user.UserManager
import com.flipcash.shared.amountentry.AmountEntryDelegate
import com.flipcash.shared.amountentry.AmountEntryLabel
Expand Down Expand Up @@ -890,19 +892,26 @@ internal class ChatViewModel @Inject constructor(
val storedReactions = MessageReactions.from(message.reactions)

// A message the viewer sent needs no attribution, and neither does a DM —
// `profiles` is null for one. Asking for a profile that is missing is what keeps
// a sender from showing as a blank name for the rest of the session; the
// resolver dedupes, so asking once per page is asking once.
// `profiles` is null for one. Asking for a profile that is missing, or cached
// with no name, is what keeps a sender from showing as a blank name for the
// rest of the session; the resolver dedupes, so asking once per page is asking
// once.
val sender = profiles?.let { resolved ->
message.senderId?.takeIf { !message.isFromSelf }?.let { senderId ->
val profile = resolved[senderId.hexEncodedString()]
if (profile == null) {
if (profile == null || profile.displayName.isBlank()) {
chatCoordinator.requestSenderProfile(senderId)
}
if (profile == null) {
null
} else {
SenderIdentity(
userId = senderId,
displayName = profile.displayName,
displayName = nameOrHandle(profile.displayName, profile.handle)
?: resources.getString(R.string.title_unnamedUser),
// Only a name the person chose gives initials; "Flipcash User"
// would put the same "FU" on every one of them.
initialsName = nameOrHandle(profile.displayName, profile.handle),
picture = profile.profilePicture,
)
}
Expand Down Expand Up @@ -1007,9 +1016,16 @@ internal class ChatViewModel @Inject constructor(
val profiles = senderProfiles.value ?: return stateFlow.value.participant?.name.orEmpty()
val senderId = senderId ?: return ""
val profile = profiles[senderId.hexEncodedString()]
?: return "".also { chatCoordinator.requestSenderProfile(senderId) }
if (profile == null || profile.displayName.isBlank()) {
chatCoordinator.requestSenderProfile(senderId)
}

return profile.displayName
return when (profile) {
// Still resolving: a placeholder name here would flash before the real one lands.
null -> ""
else -> nameOrHandle(profile.displayName, profile.handle)
?: resources.getString(R.string.title_unnamedUser)
}
}

private suspend fun ChatMessage.toQuote(): ChatQuote {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -329,7 +329,7 @@ internal fun MessageRow(
if (sender != null && runStart) {
ContactAvatar(
image = sender.picture,
displayName = sender.displayName,
displayName = sender.initialsName.orEmpty(),
access = BlobAccessContext.profile(sender.userId),
modifier = Modifier
.fillMaxSize()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -273,7 +273,9 @@ internal fun ProfileHeader(
// Wider than the 5dp that binds the identity lines below it, so the name reads as the
// start of that block rather than as another line of the picture.
modifier = Modifier.padding(top = CodeTheme.dimens.grid.x3),
text = participant?.name.orEmpty(),
// Left empty only while the participant loads; a person with neither a name nor a
// handle is still called something.
text = participant?.let { it.name ?: stringResource(R.string.title_unnamedUser) }.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,6 +60,8 @@ internal class ChatProfileViewModel @Inject constructor(
val fromServer: Boolean = false,
) : Event
data class JoinDateLoaded(val joinDate: Instant?) : Event
/** The participant as the server has them, replacing the cached one the screen opened on. */
data class ProfileLoaded(val participant: ChatParticipant.TipUser) : Event
data object BlockUser : Event
data class BlockConfirmed(val participant: ChatParticipant.TipUser) : Event
data class BlockProcessing(val loading: Boolean = false, val success: Boolean = false): Event
Expand All @@ -73,14 +75,18 @@ internal class ChatProfileViewModel @Inject constructor(
.distinctUntilChanged()
.onEach { event ->
val (userId, profile) = event.participant as ChatParticipant.TipUser
// The cached member profile doesn't carry a join date, so resolve it from the
// server profile, falling back to whatever the participant already had.
val joinDate = if (event.fromServer) {
profile.joinedAt
} else {
profiles.getProfileForUser(userId).getOrNull()?.joinedAt ?: profile.joinedAt
if (event.fromServer) {
dispatchEvent(Event.JoinDateLoaded(profile.joinedAt))
return@onEach
}
dispatchEvent(Event.JoinDateLoaded(joinDate))
// The participant came from the cache, which carries no join date and may carry a
// roster page's blank name. The server profile replaces it whole; the cached one
// stays when the fetch fails.
val fetched = profiles.getProfileForUser(userId).getOrNull()
if (fetched != null) {
dispatchEvent(Event.ProfileLoaded(ChatParticipant.TipUser(userId, fetched)))
}
dispatchEvent(Event.JoinDateLoaded(fetched?.joinedAt ?: profile.joinedAt))
}
.launchIn(viewModelScope)

Expand All @@ -104,7 +110,7 @@ internal class ChatProfileViewModel @Inject constructor(
// "Block ?" is what this read for an account with no display name.
title = resources.getString(
R.string.prompt_title_blockUser,
participant.name.orEmpty(),
participant.name ?: resources.getString(R.string.title_unnamedUser),
),
message = resources.getString(R.string.prompt_description_blockUser),
actions = listOf(
Expand Down Expand Up @@ -145,6 +151,7 @@ internal class ChatProfileViewModel @Inject constructor(
when (event) {
is Event.OnParticipantSet -> { state -> state.copy(participant = event.participant) }
is Event.JoinDateLoaded -> { state -> state.copy(joinDate = event.joinDate) }
is Event.ProfileLoaded -> { state -> state.copy(participant = event.participant) }
is Event.BlockProcessing -> { state ->
val current = state.processingState
state.copy(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ import org.junit.Test
import kotlin.test.assertEquals
import kotlin.time.Instant

/** Where a person's profile gets its join date from. */
/** Where a person's profile gets its name and join date from. */
@OptIn(ExperimentalCoroutinesApi::class)
class ChatProfileViewModelTest {

Expand All @@ -46,10 +46,10 @@ class ChatProfileViewModelTest {
resources = mockk<ResourceHelper>(relaxed = true),
)

private fun participant(joinedAt: Instant?) = ChatParticipant.TipUser(
private fun participant(joinedAt: Instant?, displayName: String = "Sally") = ChatParticipant.TipUser(
userId = theirId,
profile = UserProfile(
displayName = "Sally",
displayName = displayName,
socialAccounts = emptyList(),
phoneNumber = null,
email = null,
Expand All @@ -71,6 +71,32 @@ class ChatProfileViewModelTest {
coVerify(exactly = 1) { profiles.getProfileForUser(theirId) }
}

@Test
fun `a cached participant with no name takes the server's`() = runTest {
coEvery { profiles.getProfileForUser(theirId) } returns
Result.success(participant(serverJoin).profile)
val model = viewModel()

model.dispatchEvent(
ChatProfileViewModel.Event.OnParticipantSet(participant(cachedJoin, displayName = ""))
)

assertEquals("Sally", model.stateFlow.value.participant?.name)
}

@Test
fun `a failed fetch keeps the cached participant`() = runTest {
coEvery { profiles.getProfileForUser(theirId) } returns Result.failure(Exception("offline"))
val model = viewModel()

model.dispatchEvent(
ChatProfileViewModel.Event.OnParticipantSet(participant(cachedJoin, displayName = "Cached"))
)

assertEquals("Cached", model.stateFlow.value.participant?.name)
assertEquals(cachedJoin, model.stateFlow.value.joinDate)
}

@Test
fun `a freshly fetched profile keeps its join date without fetching again`() = runTest {
val model = viewModel()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,12 @@ import com.getcode.opencode.model.core.ID
*/
data class SenderIdentity(
val userId: ID,
/** What the name line says: their name, else their handle, else a generic label. */
val displayName: String,
val picture: MediaItem?,
/**
* What the avatar draws initials from when there is no [picture]. Null when [displayName] is
* the generic label, so a nameless sender gets the plain gradient rather than its initials.
*/
val initialsName: String? = displayName,
)
Original file line number Diff line number Diff line change
Expand Up @@ -353,7 +353,8 @@ interface MessagingOperations {

/**
* Asks for [userId]'s profile if nothing has asked already, for a sender the roster subset
* does not cover. Returns immediately; the answer arrives through [observeSenderProfiles].
* does not cover or whose cached profile has no name. Returns immediately; the answer arrives
* through [observeSenderProfiles].
*/
fun requestSenderProfile(userId: ID)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,9 @@ import kotlin.time.Duration.Companion.seconds
* Three things keep that from turning into a call per emission:
*
* - **A resolved sender stays marked.** Their profile is in `user_profiles`, so the transcript
* reads it from [profiles] and never reaches [request] again.
* reads it from [profiles]. The transcript also asks again for a cached profile with no name,
* since that row may have come from a roster page rather than `GetProfile`; the mark is what
* stops an account that really has no name from being fetched on every emission.
* - **An unresolvable sender stays marked.** See [unresolvable].
* - **A sender whose fetch failed for any other reason is held for [RETRY_BACKOFF].** See
* [inFlight].
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,15 +20,16 @@ interface UserProfileDao {
suspend fun upsertFull(profiles: List<UserProfileEntity>)

/**
* Partial write for callers that only know name + avatar (blocklist sync). Inserts a new
* row, or updates *only* those two columns on an existing one — so it never downgrades a
* richer profile already cached from a chat, and never clears a pending migration blob.
* Partial write for callers that only know a user's public identity — name, avatar and handle
* (blocklist sync, a single `GetProfile`). Inserts a new row, or updates *only* those three
* columns on an existing one — so it never downgrades a richer profile already cached from a
* chat, and never clears a pending migration blob.
*
* Uses `INSERT OR REPLACE` with correlated sub-selects (rather than `ON CONFLICT DO
* UPDATE`) so it works on the minSdk-29 SQLite build, which predates UPSERT. The
* sub-selects read the current row before the replace, preserving every column the
* blocklist doesn't know about. The name and avatar keep their existing values when
* [displayName] or [profilePicture] is null, via COALESCE: null means the caller has no
* blocklist doesn't know about. The name, avatar and handle keep their existing values when
* [displayName], [profilePicture] or [username] is null, via COALESCE: null means the caller has no
* profile to write (a failed fetch), whereas an empty name is one the user really has. A
* null name with nothing cached stores an empty one, since the column is NOT NULL.
*/
Expand All @@ -47,12 +48,17 @@ interface UserProfileDao {
(SELECT email_verified FROM user_profiles WHERE user_id_hex = :userIdHex),
(SELECT social_accounts_json FROM user_profiles WHERE user_id_hex = :userIdHex),
COALESCE(:profilePicture, (SELECT profile_picture_json FROM user_profiles WHERE user_id_hex = :userIdHex)),
(SELECT username FROM user_profiles WHERE user_id_hex = :userIdHex),
COALESCE(:username, (SELECT username FROM user_profiles WHERE user_id_hex = :userIdHex)),
(SELECT pending_migration_json FROM user_profiles WHERE user_id_hex = :userIdHex)
)
"""
)
suspend fun upsertNameAndAvatar(userIdHex: String, displayName: String?, profilePicture: MediaItem?)
suspend fun upsertNameAndAvatar(
userIdHex: String,
displayName: String?,
profilePicture: MediaItem?,
username: String?,
)

/** The cached profile for [userIdHex], or null if none is cached. */
@Query("SELECT * FROM user_profiles WHERE user_id_hex = :userIdHex LIMIT 1")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -179,7 +179,7 @@ class UserProfileDaoTest {
dao.upsertFull(listOf(fullProfile("u1")))

// Blocklist sync only knows name + avatar — must not wipe phone/email/social.
dao.upsertNameAndAvatar(userIdHex = "u1", displayName = "Alice (blocked)", profilePicture = null)
dao.upsertNameAndAvatar(userIdHex = "u1", displayName = "Alice (blocked)", profilePicture = null, username = null)

db.openHelper.writableDatabase.query("SELECT display_name, phone_value, email_value FROM user_profiles WHERE user_id_hex = 'u1'").use {
it.moveToFirst()
Expand All @@ -195,18 +195,28 @@ class UserProfileDaoTest {
dao.upsertFull(listOf(fullProfile("u1")))

// The blocklist has no username to write; the handle must survive its sync.
dao.upsertNameAndAvatar(userIdHex = "u1", displayName = "Alice (blocked)", profilePicture = null)
dao.upsertNameAndAvatar(userIdHex = "u1", displayName = "Alice (blocked)", profilePicture = null, username = null)

assertEquals("alice", dao.getByUserId("u1")?.username)
}

@Test
fun `partial write stores the handle a fetched profile carries`() = runBlocking {
val dao = db.userProfileDao()

// A sender resolved by `GetProfile` has no roster row; their handle is the fallback name.
dao.upsertNameAndAvatar(userIdHex = "u2", displayName = "", profilePicture = null, username = "bob")

assertEquals("bob", dao.getByUserId("u2")?.username)
}

@Test
fun `partial write with no name keeps the cached name`() = runBlocking {
val dao = db.userProfileDao()
dao.upsertFull(listOf(fullProfile("u1")))

// The blocklist writes no name when the profile fetch failed; that is not a rename.
dao.upsertNameAndAvatar(userIdHex = "u1", displayName = null, profilePicture = null)
dao.upsertNameAndAvatar(userIdHex = "u1", displayName = null, profilePicture = null, username = null)

assertEquals("Alice", dao.getByUserId("u1")?.displayName)
}
Expand All @@ -215,7 +225,7 @@ class UserProfileDaoTest {
fun `partial write with no name and nothing cached stores an empty name`() = runBlocking {
val dao = db.userProfileDao()

dao.upsertNameAndAvatar(userIdHex = "u1", displayName = null, profilePicture = null)
dao.upsertNameAndAvatar(userIdHex = "u1", displayName = null, profilePicture = null, username = null)

assertEquals("", dao.getByUserId("u1")?.displayName)
}
Expand All @@ -226,7 +236,7 @@ class UserProfileDaoTest {
dao.upsertFull(listOf(fullProfile("u1")))

// A fetched profile with no name is the user's own choice, not a failed lookup.
dao.upsertNameAndAvatar(userIdHex = "u1", displayName = "", profilePicture = null)
dao.upsertNameAndAvatar(userIdHex = "u1", displayName = "", profilePicture = null, username = null)

assertEquals("", dao.getByUserId("u1")?.displayName)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ class BlockedUserDataSource @Inject constructor(
userIdHex = resolved.blocked.userId.hexEncodedString(),
displayName = resolved.profile?.displayName,
profilePicture = resolved.profile?.profilePicture,
username = resolved.profile?.username,
)
}
}
Loading
Loading