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