Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 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
26 changes: 25 additions & 1 deletion mobile/lib/features/channels/channel_messages_provider.dart
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,11 @@ import 'pending_local_messages_provider.dart';
import 'channel_window.dart';
import 'thread_replies_provider.dart';

const _channelLiveEventKinds = [
...EventKind.channelEventKinds,
EventKind.channelThreadSummary,
Comment thread
klopez4212 marked this conversation as resolved.
];

/// Provides the message list for a specific channel. Registers a live
/// subscription first, then syncs history via the server-assembled channel
/// window fast path, falling back to the legacy websocket history path when the
Expand Down Expand Up @@ -71,7 +76,7 @@ class ChannelMessagesNotifier extends Notifier<AsyncValue<List<NostrEvent>>> {
try {
final unsubscribe = await session.subscribe(
NostrFilter(
kinds: EventKind.channelEventKinds,
kinds: _channelLiveEventKinds,
Comment thread
klopez4212 marked this conversation as resolved.
tags: {
'#h': [channelId],
},
Expand Down Expand Up @@ -168,6 +173,19 @@ class ChannelMessagesNotifier extends Notifier<AsyncValue<List<NostrEvent>>> {
);

void _handleLiveEvent(NostrEvent event, {bool authoritative = true}) {
// A live summary can race the initial channel-window query. Buffer it in
// the window store even before that query installs its first page, rather
// than treating metadata as an ordinary websocket timeline event.
if (event.kind == EventKind.channelThreadSummary && !_usingChannelWindow) {
if (_mergeWindowEventIntoStore(event) && !_initInFlight) {
final current =
state.value ?? _lastKnownMessages ?? const <NostrEvent>[];
_lastKnownMessages = current;
state = AsyncData(current);
}
return;
}

// Reply ownership and its thread-local overlay must transition together.
// The authoritative thread query performs both confirmations after it
// contains the reply; a live echo only triggers that query below.
Expand Down Expand Up @@ -222,7 +240,12 @@ class ChannelMessagesNotifier extends Notifier<AsyncValue<List<NostrEvent>>> {
}
if (!_isBroadcastReply(event)) return false;
}
// Thread summaries are neither a timeline row nor an aux event, but they are
// how the root's "N replies" row learns a reply landed — a reply itself
// never reaches the main timeline. Dropping them here meant the count only
// appeared after leaving the channel and coming back, which refetched.
if (!isTimelineRow &&
event.kind != EventKind.channelThreadSummary &&
!EventKind.channelAuxEventKinds.contains(event.kind)) {
Comment thread
klopez4212 marked this conversation as resolved.
return false;
}
Expand Down Expand Up @@ -320,6 +343,7 @@ class ChannelMessagesNotifier extends Notifier<AsyncValue<List<NostrEvent>>> {
pages: _windowStore.pages,
liveOverlay: nextOverlay,
liveAux: _windowStore.liveAux,
liveThreadSummaries: _windowStore.liveThreadSummaries,
);
}

Expand Down
Loading
Loading