Skip to content

feat(chat): name a person by their handle when they have no display name - #1335

Merged
bmc08gt merged 2 commits into
code/cashfrom
feat/chat-handles
Aug 26, 2026
Merged

feat(chat): name a person by their handle when they have no display name#1335
bmc08gt merged 2 commits into
code/cashfrom
feat/chat-handles

Conversation

@bmc08gt

@bmc08gt bmc08gt commented Aug 26, 2026

Copy link
Copy Markdown
Collaborator

Follows #1330, which gave every account a public @handle. Chat still identified a person by displayName alone, and a TIP_DM counterparty is not required to have one — onboarding forces a name only for new accounts, and "Change Username" is reachable with includeName = false. FeedSyncDelegate keeps 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) in Handle.kt: the display name when there is one, the @handle when 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 ?: handle scattered across call sites:

Property Used by
ChatParticipant.name conversation top bar, info card, profile sheet, block prompt
ConversationReference.name tips list row (ConversationReference gains a handle field, populated in toConversationReference)
BlockedUserProfile.name blocklist row, unblock prompt

ChatParticipant.Contact.handle stays 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.

InitialsText now 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

ProtobufToLocal sets username on the wire model, but the chat cache dropped it. UserProfileEntity had no column and UserProfileSerialized had no field, and both the feed and the open conversation read members from Room rather than from the fetch response — MessagingDelegateChatMemberDataSource.observeMembers, and FeedSyncDelegate.observeFeedFromDb. UserProfile.handle was null on every read path, which means the info card handle line shipped in #1330 has never rendered.

Carrying username through fixes that and is what makes the rest of this change observable:

  • nullable username column on UserProfileEntity, field on UserProfileSerialized and its tolerant compat model
  • UserProfileEntityMapping.toSerialized, UserProfileDomainMappers.toDomain, ChatEntityMapper.toProfileEntity, BlockedUserEntityToProfileMapper
  • upsertNameAndAvatar preserves 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 one
  • database 30 → 31 with AutoMigration(from = 30, to = 31) and exported 31.json, the same nullable-column shape as 26 → 27 and 29 → 30

Screenshot 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 a
name-less tip DM is an emulator and a live tip from an account that never set a name. Mechanics
follow TokenCardWatermarkScreenshotTest.

ProfileHeader goes from private to @VisibleForTesting internal so the messenger test can
render it.

Out of scope, worth knowing

ChatViewModel runs its own identity check with no tip-DM exemption, unlike FeedSyncDelegate. Opening a name-less, phone-less tip DM therefore lands on DeactivatedChatBottomBar and the chat is read-only. That is a separate behaviour from naming and is untouched here.

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.
@bmc08gt bmc08gt self-assigned this Aug 26, 2026
@github-actions github-actions Bot added type: feature New functionality area: network gRPC, connectivity, API, exchange rates labels Aug 26, 2026
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.
@bmc08gt
bmc08gt merged commit 2a40376 into code/cash Aug 26, 2026
3 checks passed
@bmc08gt
bmc08gt deleted the feat/chat-handles branch August 27, 2026 15:41
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area: network gRPC, connectivity, API, exchange rates type: feature New functionality

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant