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, ) }