diff --git a/mobile/lib/features/channels/channel_detail_page.dart b/mobile/lib/features/channels/channel_detail_page.dart index 20e5bfa96c..f9525c3faa 100644 --- a/mobile/lib/features/channels/channel_detail_page.dart +++ b/mobile/lib/features/channels/channel_detail_page.dart @@ -362,17 +362,6 @@ class _ResolvedChannelDetailPage extends HookConsumerWidget { initialForcedUnreadMessageIdsRef.value; // Ownership comes from the signing identity, not reloadable profile metadata. final currentPubkey = ref.watch(currentPubkeyProvider); - // Me flattens replies into this timeline, so include their typing signals. - // Ordinary channels keep thread activity inside the thread view. - final typingEntries = ref - .watch( - visibleTypingProvider( - channel.id, - selfPubkey: currentPubkey, - include: (e) => personalChat || e.threadHeadId == null, - ), - ) - .entries; final baseChannel = listedChannel ?? channel; final resolvedChannel = detailsAsync.whenData(baseChannel.mergeDetails).value ?? baseChannel; @@ -453,6 +442,21 @@ class _ResolvedChannelDetailPage extends HookConsumerWidget { channelBotPubkeys: channelBotPubkeysState.asData?.value ?? const {}, ); + // Agent work is visible in every DM, even when its response is threaded. + // Ordinary channels keep thread activity inside the thread view. + final typingEntries = ref + .watch( + visibleTypingProvider( + channel.id, + selfPubkey: currentPubkey, + include: (e) => + personalChat || + e.threadHeadId == null || + (resolvedChannel.isDm && + agentPubkeys.contains(e.pubkey.toLowerCase())), + ), + ) + .entries; useEffect(() { if (sessionStatus != SessionStatus.connected || identitySubscriptionPubkeys.isEmpty) { diff --git a/mobile/lib/features/channels/channel_detail_page/message_bubble.dart b/mobile/lib/features/channels/channel_detail_page/message_bubble.dart index 3f8ec5442a..325541d7b3 100644 --- a/mobile/lib/features/channels/channel_detail_page/message_bubble.dart +++ b/mobile/lib/features/channels/channel_detail_page/message_bubble.dart @@ -349,8 +349,7 @@ class _MessageBubble extends HookConsumerWidget { content: message.content, bubble: true, hasReactions: - message.reactions.isNotEmpty || - replyFooter != null, + message.reactions.isNotEmpty, bubbleEnd: showAvatar, bubbleStart: showAuthor, ownMessage: ownMessage, diff --git a/mobile/test/features/channels/channel_detail_page_test.dart b/mobile/test/features/channels/channel_detail_page_test.dart index 0ae2f7fb11..836d8dd517 100644 --- a/mobile/test/features/channels/channel_detail_page_test.dart +++ b/mobile/test/features/channels/channel_detail_page_test.dart @@ -11120,14 +11120,39 @@ void main() { expect(findRichText('Here is the reply'), findsOneWidget); }); - for (final personal in [true, false]) { - testWidgets('reply-scoped typing is visible only in flat Me: $personal', ( + for (final scenario in [ + (personal: true, dm: false, agent: true, group: false, visible: true), + (personal: false, dm: false, agent: true, group: false, visible: false), + (personal: false, dm: true, agent: true, group: false, visible: true), + (personal: false, dm: true, agent: false, group: false, visible: false), + (personal: false, dm: true, agent: true, group: true, visible: true), + ]) { + testWidgets('reply-scoped typing follows conversation scope: $scenario', ( tester, ) async { final typing = _FakeTypingNotifier(const []); await tester.pumpWidget( _buildTestable( - personalChat: personal, + personalChat: scenario.personal, + knownAgentPubkeys: scenario.agent ? {'alice'} : {}, + channel: scenario.dm + ? Channel( + id: _channelId, + name: 'DM', + channelType: 'dm', + visibility: 'private', + description: '', + createdBy: 'self', + createdAt: DateTime(2025), + memberCount: scenario.group ? 3 : 2, + participantPubkeys: [ + 'self', + 'alice', + if (scenario.group) 'bob', + ], + isMember: true, + ) + : null, messages: [ _textMsg(id: 'prompt', pubkey: 'self', content: 'Hello'), ], @@ -11149,7 +11174,7 @@ void main() { await tester.pumpAndSettle(); expect( find.byKey(const ValueKey('inline-agent-typing-alice')), - personal ? findsOneWidget : findsNothing, + scenario.visible ? findsOneWidget : findsNothing, ); expect(find.text('Alice is typing…'), findsNothing); typing.setEntries(const []); diff --git a/mobile/test/features/channels/channel_detail_page_test/bubble_tests.dart b/mobile/test/features/channels/channel_detail_page_test/bubble_tests.dart index de4bc69616..c5ac8810d5 100644 --- a/mobile/test/features/channels/channel_detail_page_test/bubble_tests.dart +++ b/mobile/test/features/channels/channel_detail_page_test/bubble_tests.dart @@ -1,6 +1,40 @@ part of '../channel_detail_page_test.dart'; void bubbleLayoutTests() { + testWidgets('reply footer does not add reaction padding inside the bubble', ( + tester, + ) async { + await tester.pumpWidget( + _buildTestable( + messages: [ + _textMsg(id: 'plain', pubkey: 'alice', content: 'Same text'), + _textMsg(id: threadIdRoot, pubkey: 'alice', content: 'Same text'), + _textMsg( + id: threadIdR1, + pubkey: 'bob', + content: 'Reply', + extraTags: [ + ['e', threadIdRoot, '', 'reply'], + ], + ), + ], + ), + ); + await tester.pumpAndSettle(); + Finder bubble(String id) => find + .descendant( + of: find.byKey(ValueKey('message-row-$id')), + matching: find.byType(LocalSendBubbleSurface), + ) + .first; + expect(find.text('1 reply'), findsOneWidget); + expect( + tester.getSize(bubble(threadIdRoot)).height, + closeTo(tester.getSize(bubble('plain')).height, 0.01), + ); + expect(tester.takeException(), isNull); + }); + for (final author in ['self', 'alice']) { testWidgets('date divider owns equal space around $author messages', ( tester,