Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions mobile/lib/features/channels/channels_page.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
4 changes: 1 addition & 3 deletions mobile/lib/features/channels/channels_page/community.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions mobile/lib/features/channels/compose_bar.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
3 changes: 2 additions & 1 deletion mobile/lib/features/channels/members_sheet.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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;

Expand Down
3 changes: 2 additions & 1 deletion mobile/lib/features/invites/invite_create_provider.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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.
Expand Down
5 changes: 3 additions & 2 deletions mobile/lib/features/profile/user_profile.dart
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import 'package:flutter/foundation.dart';

import '../../shared/text/initial.dart';

@immutable
class UserProfile {
final String pubkey;
Expand Down Expand Up @@ -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.
Expand Down
3 changes: 2 additions & 1 deletion mobile/lib/features/pulse/compose_note_page.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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,
),
Expand Down
3 changes: 2 additions & 1 deletion mobile/lib/features/pulse/note_card.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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,
),
Expand Down
3 changes: 2 additions & 1 deletion mobile/lib/features/search/search_page.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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,
Expand Down
26 changes: 26 additions & 0 deletions mobile/lib/shared/text/initial.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
/// 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 — 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
/// 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();
}
30 changes: 30 additions & 0 deletions mobile/test/shared/text/initial_test.dart
Original file line number Diff line number Diff line change
@@ -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}');
});
}