From a849d022b323763e40e2ed28873e4eedb142b681 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Mon, 28 Sep 2026 20:33:10 -0400 Subject: [PATCH] fix(chat): refetch and label senders with no display name A sender whose cached profile had no name kept a blank row and an empty avatar in the transcript. Any cached row counted as resolved, so it was never fetched again, and the name line was skipped when the name was blank. Accounts created before the onboarding name step have no display name on the server, so this shows up in the ALL chat. - Refetch a sender whose cached name is blank, once per session, for bubbles and reply quotes. - Fall back from display name to handle to "Flipcash User" on the name line, reply quotes, the profile title and the block prompt, matching iOS's ConversationController.fallbackCounterpartName. The avatar keeps the plain gradient instead of drawing initials from the label. - The profile screen uses the fetched profile rather than only its join date, and keeps the cached one if the fetch fails. - Store the handle when the resolver saves a fetched profile. - Skip roster members with no name, picture or handle so a member-list sync cannot erase a profile that was just fetched. --- .../core/src/main/res/values/strings.xml | 2 ++ .../app/messenger/internal/ChatViewModel.kt | 30 +++++++++++++---- .../internal/screens/components/MessageRow.kt | 2 +- .../screens/profile/ChatProfileScreen.kt | 4 ++- .../screens/profile/ChatProfileViewModel.kt | 23 ++++++++----- .../profile/ChatProfileViewModelTest.kt | 32 +++++++++++++++++-- .../shared/chat/models/SenderIdentity.kt | 6 ++++ .../flipcash/shared/chat/ChatCoordinator.kt | 3 +- .../shared/chat/internal/SenderResolver.kt | 4 ++- .../app/persistence/dao/UserProfileDao.kt | 20 ++++++++---- .../persistence/UserProfileMigrationTest.kt | 20 +++++++++--- .../sources/BlockedUserDataSource.kt | 1 + .../sources/ChatMemberDataSource.kt | 21 ++++++++++-- .../sources/UserProfileDataSource.kt | 5 +-- 14 files changed, 135 insertions(+), 38 deletions(-) diff --git a/apps/flipcash/core/src/main/res/values/strings.xml b/apps/flipcash/core/src/main/res/values/strings.xml index 16e1a4e5df..9748cc6137 100644 --- a/apps/flipcash/core/src/main/res/values/strings.xml +++ b/apps/flipcash/core/src/main/res/values/strings.xml @@ -799,6 +799,8 @@ Settings You + + Flipcash User Withdraw as USDC diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt index be81dc2a7b..f807ac91cd 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt @@ -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 @@ -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, ) } @@ -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 { diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageRow.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageRow.kt index 9753ae75d5..cbee9ba68f 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageRow.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageRow.kt @@ -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() diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ChatProfileScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ChatProfileScreen.kt index 75aa2b8a22..57e0edd58f 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ChatProfileScreen.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ChatProfileScreen.kt @@ -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, diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ChatProfileViewModel.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ChatProfileViewModel.kt index 66dcbd8a9c..bb36999d2a 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ChatProfileViewModel.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ChatProfileViewModel.kt @@ -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 @@ -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) @@ -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( @@ -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( diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/screens/profile/ChatProfileViewModelTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/screens/profile/ChatProfileViewModelTest.kt index f7f1e9df89..c587b028af 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/screens/profile/ChatProfileViewModelTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/screens/profile/ChatProfileViewModelTest.kt @@ -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 { @@ -46,10 +46,10 @@ class ChatProfileViewModelTest { resources = mockk(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, @@ -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() diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/SenderIdentity.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/SenderIdentity.kt index 0051d336cc..1d650473cf 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/SenderIdentity.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/SenderIdentity.kt @@ -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, ) diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ChatCoordinator.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ChatCoordinator.kt index 658a6fdb05..298b40bd86 100644 --- a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ChatCoordinator.kt +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ChatCoordinator.kt @@ -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) diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/SenderResolver.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/SenderResolver.kt index d955e6bbdb..b5a2dae50b 100644 --- a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/SenderResolver.kt +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/SenderResolver.kt @@ -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]. diff --git a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/UserProfileDao.kt b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/UserProfileDao.kt index 26bbc2eb0c..71ee0761fd 100644 --- a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/UserProfileDao.kt +++ b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/UserProfileDao.kt @@ -20,15 +20,16 @@ interface UserProfileDao { suspend fun upsertFull(profiles: List) /** - * 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. */ @@ -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") diff --git a/apps/flipcash/shared/persistence/db/src/test/kotlin/com/flipcash/app/persistence/UserProfileMigrationTest.kt b/apps/flipcash/shared/persistence/db/src/test/kotlin/com/flipcash/app/persistence/UserProfileMigrationTest.kt index 0504bcc6e2..4188406a5c 100644 --- a/apps/flipcash/shared/persistence/db/src/test/kotlin/com/flipcash/app/persistence/UserProfileMigrationTest.kt +++ b/apps/flipcash/shared/persistence/db/src/test/kotlin/com/flipcash/app/persistence/UserProfileMigrationTest.kt @@ -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() @@ -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) } @@ -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) } @@ -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) } diff --git a/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/BlockedUserDataSource.kt b/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/BlockedUserDataSource.kt index 7f0c9d07d2..ef52898c90 100644 --- a/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/BlockedUserDataSource.kt +++ b/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/BlockedUserDataSource.kt @@ -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, ) } } diff --git a/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/ChatMemberDataSource.kt b/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/ChatMemberDataSource.kt index 4881470076..12190537b9 100644 --- a/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/ChatMemberDataSource.kt +++ b/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/ChatMemberDataSource.kt @@ -69,7 +69,7 @@ class ChatMemberDataSource @Inject constructor( // member never observes a missing profile mid-write. Profiles go first (the // @Relation reads them), and use the authoritative full-profile upsert. database.withTransaction { - database.userProfileDao().upsertFull(members.map { mapper.toProfileEntity(it) }) + database.userProfileDao().upsertFull(profileRows(members)) database.chatMemberDao().upsert(members.map { mapper.toEntity(hex, it) }) } } @@ -105,7 +105,7 @@ class ChatMemberDataSource @Inject constructor( val database = db ?: return val hex = mapper.chatIdHex(chatId) database.withTransaction { - database.userProfileDao().upsertFull(members.map { mapper.toProfileEntity(it) }) + database.userProfileDao().upsertFull(profileRows(members)) database.chatMemberDao().upsert(members.map { mapper.toEntity(hex, it) }) database.chatMemberDao().deleteMembersNotIn( chatIdHex = hex, @@ -132,4 +132,21 @@ class ChatMemberDataSource @Inject constructor( suspend fun clear() { db?.chatMemberDao()?.deleteAll() } + + /** + * The `user_profiles` rows [members] can vouch for. + * + * A member can arrive with no name, picture or handle — the mapper builds a profile whether or + * not the server sent one. Writing that as a full-row replace would wipe whatever a `GetProfile` + * had already filled in, and leave a blank row the transcript cannot tell from a nameless one. Left out instead: the member row still lands, and a sender with no profile row is + * what makes the transcript fetch one. + */ + private fun profileRows(members: List) = members + .filter { member -> + val profile = member.userProfile + profile.displayName.isNotBlank() || + profile.profilePicture != null || + !profile.username.isNullOrBlank() + } + .map { mapper.toProfileEntity(it) } } diff --git a/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/UserProfileDataSource.kt b/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/UserProfileDataSource.kt index 3ef41a9db4..1c2b07b3ff 100644 --- a/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/UserProfileDataSource.kt +++ b/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/UserProfileDataSource.kt @@ -41,8 +41,8 @@ class UserProfileDataSource @Inject constructor() { } /** - * Stores [profile]'s name + avatar for [userId] (INSERT OR REPLACE, preserving any existing - * phone/email/social columns). Used to back-fill the cache after a network resolve so + * Stores [profile]'s name, avatar and handle for [userId] (INSERT OR REPLACE, preserving any + * existing phone/email/social columns). Used to back-fill the cache after a network resolve so * [observeProfiles] re-emits and consumers (e.g. the transaction list) resolve the row live. */ suspend fun store(userId: ID, profile: UserProfile) { @@ -50,6 +50,7 @@ class UserProfileDataSource @Inject constructor() { userIdHex = userId.hexEncodedString(), displayName = profile.displayName, profilePicture = profile.profilePicture, + username = profile.username, ) }