Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
5 changes: 5 additions & 0 deletions Justfile
Original file line number Diff line number Diff line change
Expand Up @@ -620,6 +620,11 @@ mobile-check:
mobile-test:
unset GIT_DIR GIT_WORK_TREE; cd {{mobile_dir}} && flutter test

# Regenerate the emoji dataset asset from desktop's emoji-mart install.
# Output is committed — rerun after bumping @emoji-mart/data.
mobile-emoji-data:
node {{mobile_dir}}/scripts/generate-emoji-data.mjs
Comment on lines +625 to +626

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@klopez4212 when does this get run? Like do we need to do this as part of CI or is some developer going to have to run this?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

mmm I think this was run to rebuild the emoji tray but I dont think its actually needed ongoing..let me ask the agents to be sure

@klopez4212 klopez4212 Jul 29, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Confirmed: this is not a runtime or normal-CI step. It was used to create the committed mobile/assets/emoji/emoji-data.json; mobile reads that bundled asset and does not load emoji-mart directly.

We should keep the generator as maintenance tooling so a future intentional bump of desktop's @emoji-mart/data can regenerate the same mobile dataset (names, shortcodes, keywords, and category order) reproducibly. It only needs to be run with that dependency update, then the resulting asset should be committed — not on every developer run or ordinary CI pass.


# Compile an unsigned Android debug APK (worktree-aware debug identity)
mobile-build-android:
./scripts/mobile-worktree-overrides.sh
Expand Down
1 change: 1 addition & 0 deletions mobile/assets/emoji/emoji-data.json

Large diffs are not rendered by default.

6 changes: 6 additions & 0 deletions mobile/lib/app.dart
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import 'features/profile/settings_profile_header.dart';
import 'features/settings/settings_page.dart';
import 'shared/auth/auth.dart';
import 'shared/deeplink/pending_deep_link_provider.dart';
import 'shared/emoji/emoji_burst.dart';
import 'shared/relay/relay.dart';
import 'shared/theme/theme.dart';
import 'shared/widgets/buzz_loading_indicator.dart';
Expand Down Expand Up @@ -89,6 +90,11 @@ class App extends HookConsumerWidget {
topSectionGradient: buzzDarkGradient,
),
themeMode: effectiveMode,
// Above the navigator, so a burst keeps playing over a pushed thread page
// or a modal sheet — the same reason desktop pins its canvas to the
// viewport rather than to the message row.
builder: (context, child) =>
EmojiBurstOverlay(child: child ?? const SizedBox.shrink()),
home: authState.when(
loading: () => const _SplashScreen(),
error: (_, _) => const PairingPage(),
Expand Down
1 change: 1 addition & 0 deletions mobile/lib/features/channels/channel_detail_page.dart
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import '../../shared/widgets/avatar_image.dart';
import '../../shared/widgets/buzz_loading_indicator.dart';
import '../../shared/widgets/frosted_app_bar.dart';
import '../../shared/widgets/frosted_scaffold.dart';
import '../../shared/widgets/keyboard_dismiss_on_drag.dart';
import '../../shared/widgets/message_author_meta.dart';
import '../../shared/widgets/skeleton.dart';
import '../profile/presence_cache_provider.dart';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,24 @@ class _MessageBubble extends ConsumerWidget {
key: ValueKey('message-row-${message.id}'),
borderRadius: BorderRadius.circular(Radii.md),
highlightColor: context.colors.primary.withValues(alpha: 0.1),
// Tap opens the thread; long-press still opens the action sheet.
// MessageContent's mention, channel-link, and media handlers consume
// their own taps, so those keep routing to the profile sheet, the
// linked channel, and the media viewer respectively.
onTap: allMessages == null
? null
: () => Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => ThreadDetailPage(
threadHead: message,
allMessages: allMessages!,
channelId: currentChannelId,
currentPubkey: currentPubkey,
isMember: isMember,
isArchived: isArchived,
),
),
),
onLongPress: () => showMessageActions(
context: context,
ref: ref,
Expand Down Expand Up @@ -148,6 +166,7 @@ class _MessageBubble extends ConsumerWidget {
baseStyle: messageBodyTextStyle.copyWith(
color: context.colors.onSurface,
),
scaleEmojiOnly: true,
mediaCarouselTrailingOverflow: Grid.gutter,
onMediaReply: allMessages == null
? null
Expand Down Expand Up @@ -193,9 +212,20 @@ class _MessageBubble extends ConsumerWidget {
),
if (message.reactions.isNotEmpty)
ReactionRow(
messageId: message.id,
reactions: message.reactions,
onToggle: (emoji) =>
toggleReaction(ref, message, emoji),
// Desktop puts the picker trigger beside existing
// reactions on every row. The row only renders once a
// message has a reaction, so this doesn't add chrome
// to a quiet timeline.
showAddButton: isMember && !isArchived,
onAddReaction: () => showAddReactionPicker(
context: context,
ref: ref,
message: message,
),
),
],
),
Expand Down
178 changes: 91 additions & 87 deletions mobile/lib/features/channels/channel_detail_page/message_list.dart
Original file line number Diff line number Diff line change
Expand Up @@ -243,106 +243,110 @@ class _MessageList extends HookConsumerWidget {
}
return false;
},
child: ScrollablePositionedList.builder(
key: const ValueKey('channel-message-list'),
itemScrollController: itemScrollController,
itemPositionsListener: itemPositionsListener,
reverse: true,
padding: EdgeInsets.only(
left: Grid.gutter,
right: Grid.gutter,
top: frostedAppBarHeight(
context,
titleContentHeight: appBarTitleContentHeight,
child: KeyboardDismissOnDrag(
child: ScrollablePositionedList.builder(
key: const ValueKey('channel-message-list'),
itemScrollController: itemScrollController,
itemPositionsListener: itemPositionsListener,
reverse: true,
padding: EdgeInsets.only(
left: Grid.gutter,
right: Grid.gutter,
top: frostedAppBarHeight(
context,
titleContentHeight: appBarTitleContentHeight,
),
bottom: 0,
),
bottom: 0,
),
itemCount: displayEntries.length + (isLoadingOlder.value ? 1 : 0),
itemBuilder: (context, index) {
// Loading indicator at the top (last index in reversed list).
if (index >= displayEntries.length) {
return const Padding(
padding: EdgeInsets.symmetric(vertical: Grid.xs),
child: Center(
child: BuzzLoadingIndicator(
size: 24,
semanticLabel: 'Loading older messages',
itemCount: displayEntries.length + (isLoadingOlder.value ? 1 : 0),
itemBuilder: (context, index) {
// Loading indicator at the top (last index in reversed list).
if (index >= displayEntries.length) {
return const Padding(
padding: EdgeInsets.symmetric(vertical: Grid.xs),
child: Center(
child: BuzzLoadingIndicator(
size: 24,
semanticLabel: 'Loading older messages',
),
),
),
);
}
);
}

// Reversed list: index 0 = newest (bottom of screen).
final chronIdx = displayEntries.length - 1 - index;
final entryGroup = displayEntries[chronIdx];
final entry = entryGroup.first;
final message = entry.message;
// Reversed list: index 0 = newest (bottom of screen).
final chronIdx = displayEntries.length - 1 - index;
final entryGroup = displayEntries[chronIdx];
final entry = entryGroup.first;
final message = entry.message;

// Day boundary check — applies to all messages including system.
final prevEntry = chronIdx > 0
? displayEntries[chronIdx - 1].last
: null;
final prevMessage = prevEntry?.message;
final showDayDivider =
prevMessage == null ||
!isSameDay(prevMessage.createdAt, message.createdAt);
// Day boundary check — applies to all messages including system.
final prevEntry = chronIdx > 0
? displayEntries[chronIdx - 1].last
: null;
final prevMessage = prevEntry?.message;
final showDayDivider =
prevMessage == null ||
!isSameDay(prevMessage.createdAt, message.createdAt);

final showAuthor =
!message.isSystem &&
(message.hasAttachments ||
prevMessage == null ||
prevMessage.isSystem ||
showDayDivider ||
prevMessage.pubkey.toLowerCase() !=
message.pubkey.toLowerCase() ||
(message.createdAt - prevMessage.createdAt) > 300);
final showAuthor =
!message.isSystem &&
(message.hasAttachments ||
prevMessage == null ||
prevMessage.isSystem ||
showDayDivider ||
prevMessage.pubkey.toLowerCase() !=
message.pubkey.toLowerCase() ||
(message.createdAt - prevMessage.createdAt) > 300);

return Padding(
key: ValueKey('channel-message-group-${message.id}'),
padding: EdgeInsets.only(bottom: index == 0 ? Grid.xs : 0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (showDayDivider)
DayDivider(label: formatDayHeading(message.createdAt)),
if (message.isSystem)
_SystemMessageRow(
message: message,
groupedMessages: entryGroup.length > 1
? entryGroup.map((entry) => entry.message).toList()
: null,
channelId: channelId,
currentPubkey: currentPubkey,
allMessages: null,
isMember: isMember,
isArchived: isArchived,
)
else ...[
_MessageBubble(
message: message,
showAuthor: showAuthor,
channelNames: channelNamesMap,
currentChannelId: channelId,
currentPubkey: currentPubkey,
allMessages: allMessages,
isMember: isMember,
isArchived: isArchived,
),
if (entry.summary != null)
_ThreadSummaryRow(
summary: entry.summary!,
return Padding(
key: ValueKey('channel-message-group-${message.id}'),
padding: EdgeInsets.only(bottom: index == 0 ? Grid.xs : 0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (showDayDivider)
DayDivider(label: formatDayHeading(message.createdAt)),
if (message.isSystem)
_SystemMessageRow(
message: message,
allMessages: allMessages,
groupedMessages: entryGroup.length > 1
? entryGroup
.map((entry) => entry.message)
.toList()
: null,
channelId: channelId,
currentPubkey: currentPubkey,
allMessages: null,
isMember: isMember,
isArchived: isArchived,
)
else ...[
_MessageBubble(
message: message,
showAuthor: showAuthor,
channelNames: channelNamesMap,
currentChannelId: channelId,
currentPubkey: currentPubkey,
allMessages: allMessages,
isMember: isMember,
isArchived: isArchived,
),
if (entry.summary != null)
_ThreadSummaryRow(
summary: entry.summary!,
message: message,
allMessages: allMessages,
channelId: channelId,
currentPubkey: currentPubkey,
isMember: isMember,
isArchived: isArchived,
),
],
],
],
),
);
},
),
);
},
),
),
),
if (!isAtLatest.value)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,7 @@ class _SystemMessageRow extends ConsumerWidget {
: Grid.xxs),
),
child: ReactionRow(
messageId: message.id,
reactions: reactions,
onToggle: groupedMessages == null
? (emoji) => toggleReaction(ref, message, emoji)
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 @@ -18,6 +18,7 @@ import '../../shared/relay/relay.dart';
import '../../shared/theme/theme.dart';
import '../../shared/widgets/avatar_image.dart';
import '../../shared/widgets/buzz_loading_indicator.dart';
import '../../shared/widgets/keyboard_dismiss_on_drag.dart';
import '../profile/user_cache_provider.dart';
import '../profile/user_profile.dart';
import '../../shared/custom_emoji/custom_emoji.dart';
Expand Down
42 changes: 42 additions & 0 deletions mobile/lib/features/channels/compose_bar/layout.dart
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,10 @@ class _ComposeBarLayout extends StatelessWidget {

@override
Widget build(BuildContext context) {
return _DragDownToDismissKeyboard(child: _buildBar(context));
}

Widget _buildBar(BuildContext context) {
return Container(
decoration: BoxDecoration(
color: context.colors.surfaceContainerHighest,
Expand Down Expand Up @@ -243,3 +247,41 @@ class _ComposeBarLayout extends StatelessWidget {
);
}
}

/// Drag the compose bar downward to put the keyboard away.
///
/// Continues the gesture the message list starts: once your finger reaches the
/// composer, keep pulling down and the keyboard goes with it. Uses a raw
/// [Listener] rather than a `GestureDetector` on purpose — a gesture recognizer
/// here would enter the arena against the `TextField` and could steal taps,
/// caret placement, and selection drags. A [Listener] only observes.
class _DragDownToDismissKeyboard extends HookWidget {
final Widget child;

const _DragDownToDismissKeyboard({required this.child});

@override
Widget build(BuildContext context) {
// A ref, not state: pointer travel must not rebuild the composer, which
// would churn the TextField mid-gesture.
final downwardTravel = useRef(0.0);

return Listener(
onPointerDown: (_) => downwardTravel.value = 0,
onPointerCancel: (_) => downwardTravel.value = 0,
onPointerUp: (_) => downwardTravel.value = 0,
onPointerMove: (event) {
final dy = event.delta.dy;
if (dy <= 0) {
downwardTravel.value = 0;
return;
}
downwardTravel.value += dy;
if (downwardTravel.value < keyboardDismissDragThreshold) return;
downwardTravel.value = 0;
dismissKeyboard(context);
Comment thread
klopez4212 marked this conversation as resolved.
},
child: child,
);
}
}
Loading
Loading