Skip to content

feat(chat): open @handle mentions in message bubbles - #1609

Merged
bmc08gt merged 3 commits into
code/cashfrom
feat/chat-mentions
Sep 28, 2026
Merged

bmc08gt merged 3 commits into
code/cashfrom
feat/chat-mentions

Conversation

@bmc08gt

@bmc08gt bmc08gt commented Sep 28, 2026 •

Copy link
Copy Markdown
Collaborator

Android had no mention support. This ports code-payments/code-ios-app#882, which iOS now ships to everyone, with no flag here either.

Detection

detectMentions in chat-ui uses iOS's pattern, (?:^|[^A-Za-z0-9_.@])(@([A-Za-z0-9_]{2,15}))(?![A-Za-z0-9_@]), over UTF-16 offsets. Handles are lowercased. Email addresses, @@name, and handles outside 2–15 characters or running into more handle characters are not mentions. A handle that overlaps a detected link is left to the link.

ChatViewModel's mapper detects once per message, beside detectUrls, and stores the result on ContentBubble.mentions. Mentions take no part in link-card classification. splitAroundLinkCard rebases each one into the row it falls in, and the card row gets none.

MentionDetectionTest ports MentionDetectorTests case for case. SplitAroundLinkCardTest covers iOS's mentionRebasedIntoTrailingRow and a leading-row counterpart.

Tap

ChatAction.OpenMention(username) carries only the handle. ChatViewModel looks it up with LinkCardResolver.lookUpUser, which uses the same lookup and memory as a flipcash.com/<handle> person card, so a handle a card already resolved opens without a round trip. mentionDestination picks where it lands:

Handle Opens
The viewer's own The You tab, as a person card to yourself does
The open DM's counterpart Their profile as the DM title opens it: Mute, no Message
Anyone else Their profile with Message and Send Cash, no Mute (ProfileOrigin.Mention)
Unclaimed Info dialog "No Such Account" / "Nobody has claimed @handle"
Any other failure Error dialog "Couldn't Open Profile" / "Please check your connection and try again"

ChatStep.Profile gained an origin (default Chat), and profileShowsMute and profileShortcutRecipient read it. MentionDestinationTest ports MentionDestinationTests.

Pill

White at 14%, 6dp radius, 6dp padding and a 3dp gap to the neighbouring words, drawn behind each line when the handle wraps. These are iOS's MentionPill values. I found no Figma node for the pill in either repo, so the colour hasn't been checked against the design. Compose has no per-character kerning, so the room either side of the handle is an inline placeholder with a no-break space stand-in, which keeps the pill's padding on the same line as the @ and the handle. The tap covers the whole pill. A mention's letters keep the body's colour with no underline; a clickable span with no style of its own draws as a web link.

Bubble width

Every text bubble now narrows to its widest line (widestLine in WidestLine.kt). A Compose Text that wraps reports the whole width it was offered, so any wrapped message stretched its bubble to the maximum. Mentions made it obvious: a pill wrapping onto its own line left most of the bubble empty. The body is measured once at the offered width and then given exactly its widest line. SwiftUI text sizes this way, so this matches iOS.

The transcript has no tap-to-dismiss-keyboard handler, so a first tap with the keyboard up goes to the link. iOS's first-tap bug doesn't carry over.

Ports code-ios-app#882. A `@handle` in a text or reply bubble is drawn
as a rounded pill and opens whoever it names.

Detection runs once per message in the mapper, beside the links, with
iOS's pattern. A handle inside a link (`x.com/@jeff`) stays the link's.
Mentions live on `ContentBubble.mentions`, outside card classification,
and `splitAroundLinkCard` rebases each into its own row.

A tap looks the handle up through `LinkCardResolver.lookUpUser`, which
shares the person card's lookup and memory, so a handle already
resolved opens without a round trip. Where it lands:

- the viewer's own handle: the You tab, as a person card to yourself does
- the open DM's counterpart: their profile as the title opens it, with
  Mute and without Message
- anyone else: their profile with Message and Send Cash and no Mute
  (`ProfileOrigin.Mention`)
- unclaimed: "No Such Account"; any other failure: "Couldn't Open
  Profile"

The pill uses iOS's values: white at 14%, 6dp radius, 6dp padding and
a 3dp gap, drawn per line when the handle wraps. The room either side
comes from inline placeholders, since Compose has no per-character
kerning.
@bmc08gt bmc08gt self-assigned this Sep 28, 2026
@github-actions github-actions Bot added the type: feature New functionality label Sep 28, 2026
A Compose Text that wraps reports the full width it was offered, so a
wrapped message stretched its bubble to the maximum width. A mention
pill wrapping onto its own line left most of the bubble empty. The body
now measures itself once at the offered width and takes the width of
its widest line, as SwiftUI text does on iOS.

A clickable span with no style is drawn as a web link, so mentions came
out purple and underlined over their pills. They now take the body's
colour with no underline.
#1608 split ChatProfileScreen into PersonProfileScreen, which leaves out
Mute and shows Message when no chat is behind it. A mention's profile now
passes no chat instead of carrying its own ProfileOrigin rule, and the
mention dialogs use the strings #1608 added for the same cases.
@bmc08gt
bmc08gt merged commit 3590d47 into code/cash Sep 28, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

type: feature New functionality

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant