From fc0268318f2ea3ffdc808f9eb22daebe4cb606ef Mon Sep 17 00:00:00 2001 From: Taksh Date: Sun, 16 Aug 2026 05:15:56 +0530 Subject: [PATCH 1/3] feat(mobile): add avatarInitial, which reads a character not a code unit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `label[0]` and `label.substring(0, 1)` return one UTF-16 code unit. Anything outside the Basic Multilingual Plane — every emoji, and CJK Extension B, which appears in ordinary Chinese and Japanese given names — is stored as a surrogate pair, so those return half of one: not a character, and drawn as `�` in the avatar. Reading grapheme clusters also keeps a base letter together with its combining marks, so a Devanagari or Burmese name keeps its vowel sign instead of showing a bare consonant. The `characters` package is already a direct dependency and the codebase already reaches for it where text has to be treated as text — `shared/emoji/emoji_only.dart` and the message preview in `features/channels/message_actions.dart`. This is the same rule, for avatars. No call sites yet. Signed-off-by: Taksh --- mobile/lib/shared/text/initial.dart | 25 +++++++++++++++++++ mobile/test/shared/text/initial_test.dart | 30 +++++++++++++++++++++++ 2 files changed, 55 insertions(+) create mode 100644 mobile/lib/shared/text/initial.dart create mode 100644 mobile/test/shared/text/initial_test.dart diff --git a/mobile/lib/shared/text/initial.dart b/mobile/lib/shared/text/initial.dart new file mode 100644 index 0000000000..7da56bdca2 --- /dev/null +++ b/mobile/lib/shared/text/initial.dart @@ -0,0 +1,25 @@ +/// The single character shown in an avatar when no image is available. +library; + +import 'package:characters/characters.dart'; + +/// Returns the first user-perceived character of [label], uppercased, or `?` +/// when [label] has none. +/// +/// `label[0]` and `label.substring(0, 1)` return one UTF-16 code unit. Anything +/// outside the Basic Multilingual Plane — every emoji, and CJK Extension B, +/// which appears in ordinary Chinese and Japanese given names — is stored as a +/// surrogate pair, so those return half of one: not a character, and drawn as +/// `\u{FFFD}` in the avatar. +/// +/// Grapheme clusters also keep a base letter together with its combining +/// marks, so a Devanagari or Burmese name keeps its vowel sign instead of +/// showing a bare consonant. +/// +/// Mirrors desktop's `getInitials` (`desktop/src/shared/lib/initials.ts`) in +/// treating text as characters rather than code units. +String avatarInitial(String label) { + final characters = label.characters; + if (characters.isEmpty) return '?'; + return characters.first.toUpperCase(); +} diff --git a/mobile/test/shared/text/initial_test.dart b/mobile/test/shared/text/initial_test.dart new file mode 100644 index 0000000000..edb4130b78 --- /dev/null +++ b/mobile/test/shared/text/initial_test.dart @@ -0,0 +1,30 @@ +import 'package:buzz/shared/text/initial.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('uppercases an ordinary first letter', () { + expect(avatarInitial('alice'), 'A'); + expect(avatarInitial('Bravo Beta'), 'B'); + }); + + test('falls back to ? for an empty label', () { + expect(avatarInitial(''), '?'); + }); + + test('keeps a whole astral character instead of half a surrogate pair', () { + // U+20000, CJK Extension B — an ordinary character in some names. + const name = '\u{20000}\u{660E}'; + final initial = avatarInitial(name); + expect(initial, '\u{20000}'); + expect(initial.runes.length, 1); + }); + + test('keeps an emoji whole', () { + expect(avatarInitial('\u{1F389} party'), '\u{1F389}'); + }); + + test('keeps a base letter together with its combining mark', () { + // Devanagari: the vowel sign belongs to the consonant before it. + expect(avatarInitial('\u{0928}\u{093F}\u{0932}'), '\u{0928}\u{093F}'); + }); +} From d6411ff07933a3e2990c032a190a8e813a92d345 Mon Sep 17 00:00:00 2001 From: Taksh Date: Sun, 16 Aug 2026 05:44:44 +0530 Subject: [PATCH 2/3] fix(mobile): draw a whole character in an avatar fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Seven avatar fallbacks took their letter with `label[0]` or `substring(0, 1)`. Both return a UTF-16 code unit, so a display name starting with an emoji or a CJK Extension B character produced half a surrogate pair and the avatar drew `�`: the profile model, invite and channel-management labels, the members sheet, both pulse note surfaces, and people results in search. They now go through `avatarInitial`, which also keeps a combining mark with the letter it belongs to, so a Devanagari or Burmese name no longer loses its vowel sign. The pubkey-derived fallbacks elsewhere are left alone — hex is ASCII, and taking one code unit of it was never wrong. Signed-off-by: Taksh --- .../lib/features/channels/channel_management_provider.dart | 3 ++- mobile/lib/features/channels/members_sheet.dart | 3 ++- mobile/lib/features/invites/invite_create_provider.dart | 3 ++- mobile/lib/features/profile/user_profile.dart | 5 +++-- mobile/lib/features/pulse/compose_note_page.dart | 3 ++- mobile/lib/features/pulse/note_card.dart | 3 ++- mobile/lib/features/search/search_page.dart | 3 ++- 7 files changed, 15 insertions(+), 8 deletions(-) diff --git a/mobile/lib/features/channels/channel_management_provider.dart b/mobile/lib/features/channels/channel_management_provider.dart index e33ba30c5d..618f7f3acd 100644 --- a/mobile/lib/features/channels/channel_management_provider.dart +++ b/mobile/lib/features/channels/channel_management_provider.dart @@ -9,6 +9,7 @@ import '../../shared/custom_emoji/custom_emoji.dart'; import '../../shared/custom_emoji/custom_emoji_provider.dart'; import '../../shared/mentions/agent_identity_provider.dart'; import '../../shared/relay/relay.dart'; +import '../../shared/text/initial.dart'; import '../profile/profile_provider.dart'; import 'channel.dart'; import 'channels_provider.dart'; @@ -178,7 +179,7 @@ class DirectoryUser { } /// First visible character used when no avatar image is available. - String get initial => label.isNotEmpty ? label[0].toUpperCase() : '?'; + String get initial => avatarInitial(label); } /// Whether the mobile DM directory should show local preview identities. diff --git a/mobile/lib/features/channels/members_sheet.dart b/mobile/lib/features/channels/members_sheet.dart index 54215ccd75..8c7e3b6855 100644 --- a/mobile/lib/features/channels/members_sheet.dart +++ b/mobile/lib/features/channels/members_sheet.dart @@ -3,6 +3,7 @@ import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; +import '../../shared/text/initial.dart'; import '../../shared/theme/theme.dart'; import '../../shared/widgets/avatar_image.dart'; import '../../shared/widgets/buzz_loading_indicator.dart'; @@ -220,7 +221,7 @@ class _MemberTile extends ConsumerWidget { : (profile?.displayName?.trim().isNotEmpty == true ? profile!.displayName!.trim() : member.labelFor(currentPubkey)); - final initial = label.substring(0, 1).toUpperCase(); + final initial = avatarInitial(label); final showManagementActions = canManage && !isSelf && !member.isOwner; final showMenu = showManagementActions || onViewActivity != null; diff --git a/mobile/lib/features/invites/invite_create_provider.dart b/mobile/lib/features/invites/invite_create_provider.dart index 5a6eaf2b74..94e182588e 100644 --- a/mobile/lib/features/invites/invite_create_provider.dart +++ b/mobile/lib/features/invites/invite_create_provider.dart @@ -9,6 +9,7 @@ import 'package:share_plus/share_plus.dart'; import '../../shared/community/community_membership_provider.dart'; import '../../shared/relay/relay.dart'; +import '../../shared/text/initial.dart'; /// The default lifetime of a newly minted community invite link. const defaultCommunityInviteTtlSeconds = 3 * 24 * 60 * 60; @@ -123,7 +124,7 @@ class CommunityInviteDirectoryUser { } /// The uppercase first character of [label], or `?` when unavailable. - String get initial => label.isEmpty ? '?' : label[0].toUpperCase(); + String get initial => avatarInitial(label); } /// Abbreviates a hexadecimal public key for compact display. diff --git a/mobile/lib/features/profile/user_profile.dart b/mobile/lib/features/profile/user_profile.dart index de58d955e3..5dda68c903 100644 --- a/mobile/lib/features/profile/user_profile.dart +++ b/mobile/lib/features/profile/user_profile.dart @@ -1,5 +1,7 @@ import 'package:flutter/foundation.dart'; +import '../../shared/text/initial.dart'; + @immutable class UserProfile { final String pubkey; @@ -36,8 +38,7 @@ class UserProfile { /// First letter for fallback avatar. String get initial => - (displayName?.isNotEmpty == true ? displayName! : pubkey)[0] - .toUpperCase(); + avatarInitial(displayName?.isNotEmpty == true ? displayName! : pubkey); } /// Optional profile handle shown beside a message author's display name. diff --git a/mobile/lib/features/pulse/compose_note_page.dart b/mobile/lib/features/pulse/compose_note_page.dart index d3df98ee89..2343aef4ca 100644 --- a/mobile/lib/features/pulse/compose_note_page.dart +++ b/mobile/lib/features/pulse/compose_note_page.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import '../../shared/text/initial.dart'; import '../../shared/theme/theme.dart'; import '../../shared/widgets/avatar_image.dart'; import '../../shared/widgets/buzz_loading_indicator.dart'; @@ -191,7 +192,7 @@ class _ReplyContext extends ConsumerWidget { radius: 18, backgroundColor: context.colors.primaryContainer, fallback: Text( - (profile?.initial ?? displayName[0]).toUpperCase(), + profile?.initial ?? avatarInitial(displayName), style: context.textTheme.labelMedium?.copyWith( color: context.colors.onPrimaryContainer, ), diff --git a/mobile/lib/features/pulse/note_card.dart b/mobile/lib/features/pulse/note_card.dart index d99e3ada4b..0c741d5010 100644 --- a/mobile/lib/features/pulse/note_card.dart +++ b/mobile/lib/features/pulse/note_card.dart @@ -5,6 +5,7 @@ import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:nostr/nostr.dart' as nostr; import '../../shared/clipboard_utils.dart'; +import '../../shared/text/initial.dart'; import '../../shared/theme/theme.dart'; import '../../shared/widgets/avatar_image.dart'; import '../channels/channel_detail_page.dart'; @@ -70,7 +71,7 @@ class NoteCard extends HookConsumerWidget { radius: 18, backgroundColor: context.colors.primaryContainer, fallback: Text( - (profile?.initial ?? displayName[0]).toUpperCase(), + profile?.initial ?? avatarInitial(displayName), style: context.textTheme.labelMedium?.copyWith( color: context.colors.onPrimaryContainer, ), diff --git a/mobile/lib/features/search/search_page.dart b/mobile/lib/features/search/search_page.dart index 1001e16cd1..6e868d4e76 100644 --- a/mobile/lib/features/search/search_page.dart +++ b/mobile/lib/features/search/search_page.dart @@ -6,6 +6,7 @@ import 'package:lucide_icons_flutter/lucide_icons.dart'; import '../../shared/mentions/agent_identity_provider.dart'; import '../../shared/mentions/mention_tags.dart'; +import '../../shared/text/initial.dart'; import '../../shared/theme/theme.dart'; import '../../shared/widgets/avatar_image.dart'; import '../../shared/widgets/buzz_loading_indicator.dart'; @@ -729,7 +730,7 @@ class _PeopleSection extends ConsumerWidget { key: ValueKey('search-person-leading-${user.pubkey}'), imageUrl: user.avatarUrl, radius: 20, - fallback: Text(user.label.substring(0, 1).toUpperCase()), + fallback: Text(avatarInitial(user.label)), ), title: Text( user.label, From c46fb059a74a62c9887b1c59de8acb0cd52dfc76 Mon Sep 17 00:00:00 2001 From: Taksh Date: Sun, 16 Aug 2026 07:35:09 +0530 Subject: [PATCH 3/3] fix(mobile): route the last two avatar fallbacks through avatarInitial MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review found two the earlier commit missed, both inside `part of` libraries so their imports belong on the parent: - `compose_bar/suggestions.dart` used `name[0].toUpperCase()`, which draws half a surrogate pair for an astral name and throws outright when a candidate label is empty - `channels_page/community.dart` used `trimmedName.substring(0, 1)`, so a community name starting with an astral character rendered U+FFFD `avatarInitial` covers the empty case with `?`, which is what both sites already wanted. Also corrects the doc: "every emoji" is outside the BMP was too broad. Most are; ☺ and ✌ are not. Signed-off-by: Taksh --- mobile/lib/features/channels/channels_page.dart | 1 + .../features/channels/channels_page/community.dart | 4 +--- mobile/lib/features/channels/compose_bar.dart | 1 + .../features/channels/compose_bar/suggestions.dart | 2 +- mobile/lib/shared/text/initial.dart | 11 ++++++----- 5 files changed, 10 insertions(+), 9 deletions(-) diff --git a/mobile/lib/features/channels/channels_page.dart b/mobile/lib/features/channels/channels_page.dart index c77ef278ec..0b611c7bce 100644 --- a/mobile/lib/features/channels/channels_page.dart +++ b/mobile/lib/features/channels/channels_page.dart @@ -14,6 +14,7 @@ import 'package:lucide_icons_flutter/lucide_icons.dart'; import '../../shared/auth/auth.dart'; import '../../shared/community/community_icon_provider.dart'; import '../../shared/relay/relay.dart'; +import '../../shared/text/initial.dart'; import '../../shared/theme/theme.dart'; import '../../shared/widgets/avatar_image.dart'; import '../../shared/widgets/anchored_popover_menu.dart'; diff --git a/mobile/lib/features/channels/channels_page/community.dart b/mobile/lib/features/channels/channels_page/community.dart index bd64ccc726..7368413663 100644 --- a/mobile/lib/features/channels/channels_page/community.dart +++ b/mobile/lib/features/channels/channels_page/community.dart @@ -489,9 +489,7 @@ class _CommunityAvatar extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final trimmedName = name?.trim(); - final initial = trimmedName != null && trimmedName.isNotEmpty - ? trimmedName.substring(0, 1).toUpperCase() - : '?'; + final initial = avatarInitial(trimmedName ?? ''); final relay = relayUrl; final iconUrl = relay == null ? null diff --git a/mobile/lib/features/channels/compose_bar.dart b/mobile/lib/features/channels/compose_bar.dart index 9c29c67a99..0a6f1c92c2 100644 --- a/mobile/lib/features/channels/compose_bar.dart +++ b/mobile/lib/features/channels/compose_bar.dart @@ -19,6 +19,7 @@ import 'package:nostr/nostr.dart' as nostr; import '../../shared/mentions/agent_identity_provider.dart'; import '../../shared/relay/relay.dart'; +import '../../shared/text/initial.dart'; import '../../shared/theme/theme.dart'; import '../../shared/widgets/avatar_image.dart'; import '../../shared/widgets/anchored_popover_menu.dart'; diff --git a/mobile/lib/features/channels/compose_bar/suggestions.dart b/mobile/lib/features/channels/compose_bar/suggestions.dart index 9d05858ab8..a3ef82b571 100644 --- a/mobile/lib/features/channels/compose_bar/suggestions.dart +++ b/mobile/lib/features/channels/compose_bar/suggestions.dart @@ -47,7 +47,7 @@ class _MentionSuggestions extends StatelessWidget { radius: 18, backgroundColor: context.colors.primaryContainer, fallback: Text( - name[0].toUpperCase(), + avatarInitial(name), style: context.textTheme.labelMedium?.copyWith( color: context.colors.onPrimaryContainer, fontWeight: FontWeight.w600, diff --git a/mobile/lib/shared/text/initial.dart b/mobile/lib/shared/text/initial.dart index 7da56bdca2..a1e175167a 100644 --- a/mobile/lib/shared/text/initial.dart +++ b/mobile/lib/shared/text/initial.dart @@ -6,11 +6,12 @@ import 'package:characters/characters.dart'; /// Returns the first user-perceived character of [label], uppercased, or `?` /// when [label] has none. /// -/// `label[0]` and `label.substring(0, 1)` return one UTF-16 code unit. Anything -/// outside the Basic Multilingual Plane — every emoji, and CJK Extension B, -/// which appears in ordinary Chinese and Japanese given names — is stored as a -/// surrogate pair, so those return half of one: not a character, and drawn as -/// `\u{FFFD}` in the avatar. +/// `label[0]` and `label.substring(0, 1)` return one UTF-16 code unit. +/// Anything outside the Basic Multilingual Plane — most emoji, and CJK +/// Extension B, which appears in ordinary Chinese and Japanese given names — +/// is stored as a surrogate pair, so those return half of one: not a +/// character, and drawn as `\u{FFFD}` in the avatar. They also throw on an +/// empty label; this returns `?`. /// /// Grapheme clusters also keep a base letter together with its combining /// marks, so a Devanagari or Burmese name keeps its vowel sign instead of