DEV Community

Cover image for Inline emoji shortcodes, private pins, and grouped bubbles
Frank Milvus
Frank Milvus

Posted on Originally published at frankmilvus.github.io

Inline emoji shortcodes, private pins, and grouped bubbles

This week in tonGues, a Flutter chat app for language exchange backed by a
FastAPI + PostgreSQL server, we shipped a batch of chat work: an inline
:shortcode emoji autocomplete in the composer, a per-user "pin for me"
that sits alongside the existing conversation-wide pin, and sender grouping
for message bubbles so a burst of messages from one person reads as a block.

We also ran a behavioural review pass over the whole batch before shipping it.
That pass is the interesting part: the tests were green and the analyzer was
clean, yet it caught three defects that would have shipped silently. I'll show
those at the end, because they're the reason we do the pass at all.

Inline emoji shortcodes: :joy β†’ πŸ˜‚

People type :joy, :thumbsup, :tada β€” the aliases that grew up around chat
apps β€” not the formal Unicode names. So the composer now watches the token under
the caret and offers matching emoji in a horizontal strip just above the input.
Tap a cell, or press Enter, and the token is replaced with the glyph.

The table is GitHub's gemoji set β€” 1,913 aliases β€” bundled as a JSON asset
so matching works offline. That's deliberate: the familiar shorthand (joy,
thumbsup, tada) simply isn't in Unicode's own names, so a pure CLDR-prefix
lookup would miss exactly the shortcodes people reach for.

A token scanner that stays out of the way

The hard part of inline autocomplete is not the lookup β€” it's deciding when
there is a token at all. A scanner that fires too eagerly pops the strip while
you type a URL or a clock time. The rule set we settled on is deliberately
conservative:

  • the caret must be collapsed β€” a range selection has no single token;
  • the token is : followed by at least one shortcode character before the caret, so a bare : or a trailing space ends it;
  • the : must start the text or follow whitespace, which is what keeps https://… and 10:30 from ever triggering;
  • the run stops at the first non-shortcode character (a–z, 0–9, _, +, -), so :+1, :-1 and :t-rex all survive.

Scanning backwards from the caret over ASCII-only characters has a nice side
effect: the scan can never land in the middle of a surrogate pair, so multi-unit
emoji in the text around the caret can't produce a bad slice.

Ranking so :joy beats :joy_cat

A plain "everything that starts with the query" list buries the obvious answer.
Ranking runs in three tiers, and each later tier only fills space the earlier
ones left:

// 1. exact shortcode first  β†’  :joy gives πŸ˜‚ before anything else
final exact = shortcodes[query];
if (exact != null) add(exact);

// 2. then shortcode prefixes, shortest first
//    :smi β†’ :smile before :smiley_cat
prefixes.sort((a, b) {
  final byLength = a.length.compareTo(b.length);
  return byLength != 0 ? byLength : a.compareTo(b);
});

// 3. finally gemoji tags, purely to widen recall
//    :happy, :workout β€” never ahead of a real shortcode
Enter fullscreen mode Exit fullscreen mode

That third tier is what makes :happy find πŸ˜€ even though happy isn't an
alias β€” tags are discovery-only and never outrank a real shortcode. Emoji are
de-duplicated across all three tiers, and a query longer than 32 characters is
dropped so a runaway :-run costs nothing.

Accepting a suggestion replaces exactly the typed range with the glyph plus a
trailing space, and puts the caret after it β€” the same manual-insert path the
emoji picker uses, which is why setting the text can't re-trigger the strip and
loop.

Private pins, next to conversation-wide pins

tonGues already had one pin per conversation β€” the banner at the top of the
chat that everyone sees. The new twist is a second, private pin that only
you see, synced across your own devices. The two coexist on the same message.

The data model

Both live in one join table, distinguished by a nullable user_id:

Column Meaning
conversation_id, message_id which message is pinned
pinned_by who cast the pin
user_id NULL = pin "for all"; a user id = that user's private pin

Two partial unique indexes keep it honest β€” one "for all" pin per message,
and one private pin per user per message:

-- one shared pin, and one private pin per user, for a given message
UNIQUE (conversation_id, message_id) WHERE user_id IS NULL;
UNIQUE (conversation_id, message_id, user_id) WHERE user_id IS NOT NULL;
Enter fullscreen mode Exit fullscreen mode

One service, three delivery paths

Pins can arrive over the WebSocket, over REST, or be torn down when the message
itself is deleted. Rather than let three code paths re-implement the rules β€”
membership, who may pin for everyone, whether the message is deleted β€” they all
delegate to a single pin_message / unpin_message service. The rules themselves
are small but they matter:

  • "For all" in a group is admin/owner only, and announces itself with a system row ("X pinned a message"). In a 1:1 chat both members may do it.
  • "For me" is open to any member β€” even a non-admin in a group β€” and it never emits a system row or notifies anyone. The event is echoed only to the actor's own devices.
  • Unpinning one scope leaves the other intact, so the client tracks the two flags separately (pinnedForAll / pinnedForMe) and OR-merges them on every update instead of clobbering.

The scope field defaults to "all", so a client that predates the feature
keeps working unchanged.

Grouped bubbles: one burst, one block

When the same person sends several messages in a row, they shouldn't look like
several separate conversations. Sender grouping squares the corners that touch a
neighbour and keeps the outer corners rounded, so a run reads as a single block.

The subtlety is that we compute two different things and they must not be
conflated:

  • a gap/name group β€” tight spacing and no repeated sender label;
  • a corner run β€” which corners get squared.
// The rectangle's sender-side corners follow the group position:
// first keeps its top, last keeps its bottom, a middle bubble squares both.
final radius = groupedMessageBubbleRadius(
  base,
  isMe: isMe,
  isFirstInGroup: !glueAbove,
  isLastInGroup: !glueBelow,
);
Enter fullscreen mode Exit fullscreen mode

A corner run breaks on a sender change, a day rollover, a system chip, or a
photo-album grid β€” the grid always keeps its own fully-rounded shape and
never joins a run. Keeping the two notions separate is what lets a shortcode
strip, a date separator and an album all sit in the run without producing
mismatched seams.

The defects a green build didn't catch

Before shipping the batch we run a behavioural review: read the diff end to end,
trace each event and field across the client/server boundary, and hand-verify
every candidate. The compiler and the unit tests were already happy, and it
still found three real bugs.

1. A pinned encrypted message previewed as ciphertext. The message_pinned
broadcast was hand-built in two places β€” the WebSocket handler and the REST
route β€” and both forgot the is_encrypted field that the canonical serializer
emits and the client reads. In an encrypted chat that meant the pin banner
showed the raw ciphertext instead of "πŸ”’ Encrypted message". The fix wasn't to
add the field twice; it was to delete the duplication and build the payload in
one shared function so the two paths can't drift again.

2. A half-glued bubble seam. The grouping code decided "is this neighbour an
album?" from albumId != null, but an album is only rendered as a grid when
its run is longer than one message. A lone album row β€” its siblings outside the
loaded window β€” painted as an ordinary bubble while the glue logic still refused
to join it, so one side squared a corner and the neighbour stayed rounded: a
visible notch. The fix makes the glue consult the rendered album, not the raw
field, with a test that fails against the old behaviour.

3. A floating button covering the last row. The list reserved bottom padding
for its FAB stack sized against a 40dp mini button. But a mini floating
action button lays out at 48dp on mobile β€” the 40dp circle sits inside
Material's 48dp minimum tap target. On an admin account the extra 24dp pushed the
topmost button over the last row's timestamp. The fix derives the clearance from
the real box.

None of these threw an exception. All three built, ran, passed CI, and did the
wrong thing. That's the whole argument for reviewing behaviour, not just results.

Why write it up

Each of these is small on its own. Together they're the difference between a chat
app that works and one that feels considered β€” the shortcode that completes
before you finish typing, the pin that's yours alone, the burst of messages that
reads as one thought. We write them up as we ship them so the polish gets its
share of the credit.

FAQ

Does emoji shortcode autocomplete work offline?
Yes. The shortcode table ships as a bundled asset and is loaded once, so matching
never needs the network. If the asset ever fails to load, autocomplete simply
stays inert rather than breaking the composer.

Can one message be pinned both privately and for everyone?
Yes. They're independent records in the same table, and the client keeps two
flags per message so unpinning one leaves the other in place.

Who can pin a message for the whole group?
Admins and the owner. Any member β€” admin or not β€” can always pin privately for
themselves.

How is offset handling safe with emoji in the text?
The token scanner only walks back over ASCII shortcode characters, so it stops at
the first non-ASCII unit and can never split a surrogate pair. Caret offsets and
String.length are both in UTF-16 units in Flutter, so the replacement range is
always valid.

β€” the tonGues team


Get tonGues β€” a language-exchange messenger with live translation.

Top comments (2)

Collapse
 
launchgatecheck profile image
Launch Gate •

The separate pin scopes are a useful distinction. I'd include reconnect in the privacy fixture: pin a message privately on device A, disconnect device B, then unpin or delete the message before B reconnects. Can a delayed private-pin event resurrect a stale banner after the newer state arrives? Also worth checking that a different member's initial REST history and reconnect payload never contain the private pin, even though live events only echo to the actor. That would test the read paths as well as the shared mutation service.

Collapse
 
frankmilvus profile image
Frank Milvus •

ha, good eye β€” the reconnect window is exactly the gremlin here, and ngl it’s not a case we put in the fixture. the one that worries me is a stale private-pin frame landing after the unpin/delete already reached device B β€” and yeah, the read paths are the sneaky half: live events only echoing to the actor is the easy bit; proving B’s initial history + reconnect snapshot never carry someone else’s private pin is the real test. adding both to the suite (pin private on A, kill B, unpin, reconnect β€” plus a history/reconnect assertion). will holler if either goes red. thanks for reading it that close πŸ‘€