feat(chat): name a person by their handle when they have no display name - #1335
Merged
Conversation
A TIP_DM counterparty is identified by their server profile, and a display name is not required to hold one: onboarding only forces a name for new accounts, and "Change Username" is reachable with `includeName = false`. FeedSyncDelegate keeps those name-less tip DMs in the feed on purpose. Every surface that named the person by `displayName` alone rendered an empty string with a blank avatar. `nameOrHandle(displayName, handle)` in `Handle.kt` is the one rule: the display name when there is one, the `@handle` when there isn't, null when there is neither. It backs `ChatParticipant.name`, `ConversationReference.name` and `BlockedUserProfile.name`, so the messenger, the tips list and the blocklist agree without repeating `?: handle` at each call site. The tips row, the top bar and the blocklist row are single-line, so the handle takes the name's place there; the profile sheet gains a handle line under the name, matching the info card (node 9443:8928), dropped when the name above already is the handle. `InitialsText` strips the `@` so a handle-only account gets its own first letter rather than a shared "@" avatar. The handle never reached any of this. `ProtobufToLocal` sets `username` on the wire model, but the chat cache dropped it: `UserProfileEntity` had no column, `UserProfileSerialized` had no field, and both the feed and the open conversation read members from Room rather than the fetch response. `UserProfile.handle` was therefore null everywhere, including on the info card line shipped in #1330. Carrying `username` through the entity, the serialized form, both mappers and `upsertNameAndAvatar` takes the database to 31 with a nullable-column auto-migration, the same shape as 26 -> 27 and 29 -> 30.
The five surfaces this branch changed had no way to be looked at short of an emulator and a live tip DM from a name-less account, so the handle line and the handle-as-name case went in unrendered. These render each surface in every identity state — contact, tip user with a name, tip user with only a handle — to `build/screenshots/`. They assert nothing; they exist so the layouts can be eyeballed. Mechanics are copied from TokenCardWatermarkScreenshotTest: Robolectric with native graphics, clock paused, a fixed number of frames pumped, then the content view drawn directly, which avoids captureToImage()'s waitForIdle hanging on a composable that keeps scheduling frames. The bitmap is cropped to the drawn area, since the previews wrap their content but the content view is the full device. ProfileHeader goes from private to @VisibleForTesting internal so the messenger test can render it.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Follows #1330, which gave every account a public
@handle. Chat still identified a person bydisplayNamealone, and aTIP_DMcounterparty is not required to have one — onboarding forces a name only for new accounts, and "Change Username" is reachable withincludeName = false.FeedSyncDelegatekeeps those name-less tip DMs in the feed on purpose, where a contact DM without an identity is dropped. So a tipper who never set a name rendered as an empty string with a blank avatar.One rule, not six patches
nameOrHandle(displayName, handle)inHandle.kt: the display name when there is one, the@handlewhen there isn't, null when there is neither. Blank counts as absent on both sides.It backs three derived properties, so the two halves of chat agree without
?: handlescattered across call sites:ChatParticipant.nameConversationReference.nameConversationReferencegains ahandlefield, populated intoConversationReference)BlockedUserProfile.nameChatParticipant.Contact.handlestays null by design: a contact DM is addressed by phone number, so the fallback never fires there and those surfaces keep reading the device contact's name.Where the handle goes
The tips row (node 9442:103645), the top bar (node 9443:9094) and the blocklist row are single-line, with no second line free — the handle takes the name's place. The profile sheet gains a handle line under the name in
textSmall/textSecondary, the same shape as the info card's identity line (node 9443:8928). Both that line and the info card's are dropped when the name above already is the handle, so a name-less account doesn't read it twice.InitialsTextnow strips the@before taking initials — otherwise every handle-only account shared one "@" avatar instead of getting its own first letter.The handle wasn't reaching the UI at all
ProtobufToLocalsetsusernameon the wire model, but the chat cache dropped it.UserProfileEntityhad no column andUserProfileSerializedhad no field, and both the feed and the open conversation read members from Room rather than from the fetch response —MessagingDelegate→ChatMemberDataSource.observeMembers, andFeedSyncDelegate.observeFeedFromDb.UserProfile.handlewas null on every read path, which means the info card handle line shipped in #1330 has never rendered.Carrying
usernamethrough fixes that and is what makes the rest of this change observable:usernamecolumn onUserProfileEntity, field onUserProfileSerializedand its tolerant compat modelUserProfileEntityMapping.toSerialized,UserProfileDomainMappers.toDomain,ChatEntityMapper.toProfileEntity,BlockedUserEntityToProfileMapperupsertNameAndAvatarpreserves it by sub-select, the same way it already preserves phone, email and socials — the blocklist sync has no username to write and must not wipe oneAutoMigration(from = 30, to = 31)and exported31.json, the same nullable-column shape as 26 → 27 and 29 → 30Screenshot capture
Each of the five surfaces now has a Robolectric test that renders it in every identity state —
contact, tip user with a name, tip user with only a handle — and writes a PNG to
build/screenshots/. They assert nothing; they exist because the alternative way to look at aname-less tip DM is an emulator and a live tip from an account that never set a name. Mechanics
follow
TokenCardWatermarkScreenshotTest.ProfileHeadergoes fromprivateto@VisibleForTesting internalso the messenger test canrender it.
Out of scope, worth knowing
ChatViewModelruns its own identity check with no tip-DM exemption, unlikeFeedSyncDelegate. Opening a name-less, phone-less tip DM therefore lands onDeactivatedChatBottomBarand the chat is read-only. That is a separate behaviour from naming and is untouched here.