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 b15a3f5797..1e8126f92c 100644 --- a/mobile/lib/features/channels/channel_detail_page/message_bubble.dart +++ b/mobile/lib/features/channels/channel_detail_page/message_bubble.dart @@ -39,6 +39,9 @@ class _MessageBubble extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { + final startsThreads = ref.watch( + channelStartsThreadsProvider(currentChannelId), + ); final messageSnapshotKey = useMemoized(GlobalKey.new, const []); final swipeContentKey = useMemoized(GlobalKey.new, const []); final mediaOffset = useMessageMediaOffset(message.content, message.tags); @@ -379,7 +382,8 @@ class _MessageBubble extends HookConsumerWidget { scaleEmojiOnly: true, mediaCarouselTrailingOverflow: Grid.gutter, - onMediaReply: allMessages == null + onMediaReply: + allMessages == null || !startsThreads ? null : () { if (!context.mounted) return; diff --git a/mobile/lib/features/channels/channels_provider.dart b/mobile/lib/features/channels/channels_provider.dart index 88c4e70ed9..2f1342ea9a 100644 --- a/mobile/lib/features/channels/channels_provider.dart +++ b/mobile/lib/features/channels/channels_provider.dart @@ -1155,6 +1155,18 @@ final channelByIdProvider = Provider.autoDispose.family( ref.watch(_openedAheadProvider.select((opened) => opened[channelId])), ); +/// Whether this channel can start or continue threads. A Solo chat's agents +/// answer at its top level, so it keeps existing threads readable but starts +/// none and takes no thread replies, as desktop does. A channel not loaded yet +/// keeps the default thread behavior. +final channelStartsThreadsProvider = Provider.autoDispose.family( + (ref, channelId) => !(ref.watch( + channelByIdProvider( + channelId, + ).select((channel) => channel?.isSolo ?? false), + )), +); + /// Channels opened from their own metadata before the list had them, as a /// fast DM open does. Scoped to one relay and identity. Each is dropped once /// the list has it, so a DM later hidden or left is not found through it. diff --git a/mobile/lib/features/channels/message_actions.dart b/mobile/lib/features/channels/message_actions.dart index 3d11feb856..2d463396b2 100644 --- a/mobile/lib/features/channels/message_actions.dart +++ b/mobile/lib/features/channels/message_actions.dart @@ -599,9 +599,10 @@ class _MessageQuickActionsRow extends ConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final messages = allMessages; final canRemind = ref.watch(reminderServiceProvider) != null; + final startsThreads = ref.watch(channelStartsThreadsProvider(channelId)); final tiles = [ - if (messages != null) + if (messages != null && startsThreads) _MessageQuickActionTile( icon: BuzzIcons.messageSquareReply, label: 'Reply', diff --git a/mobile/lib/features/channels/message_swipe_reply.dart b/mobile/lib/features/channels/message_swipe_reply.dart index 483bf32781..d988c23e8f 100644 --- a/mobile/lib/features/channels/message_swipe_reply.dart +++ b/mobile/lib/features/channels/message_swipe_reply.dart @@ -9,6 +9,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import '../../shared/theme/buzz_icons.dart'; import '../../shared/theme/theme.dart'; +import 'channels_provider.dart'; import 'thread_detail_page.dart'; import 'message_reply_drag_target.dart'; import 'timeline_message.dart'; @@ -55,7 +56,8 @@ class MessageSwipeReply extends HookConsumerWidget { final cancelled = useRef(false); final reducedMotion = MediaQuery.disableAnimationsOf(context); final messages = allMessages; - if (messages == null || message.isSystem) return child; + final startsThreads = ref.watch(channelStartsThreadsProvider(channelId)); + if (messages == null || message.isSystem || !startsThreads) return child; void reset() { pulse.stop(); diff --git a/mobile/lib/features/channels/thread_detail_page.dart b/mobile/lib/features/channels/thread_detail_page.dart index 9063cc6de6..888302571e 100644 --- a/mobile/lib/features/channels/thread_detail_page.dart +++ b/mobile/lib/features/channels/thread_detail_page.dart @@ -116,6 +116,11 @@ class ThreadDetailPage extends HookConsumerWidget { // signing identity instead of freezing that incomplete route snapshot. final currentPubkey = ref.watch(currentPubkeyProvider) ?? this.currentPubkey; + // A Solo chat's threads are read-only: its agents answer at top level. + final canReply = + isMember && + !isArchived && + ref.watch(channelStartsThreadsProvider(channelId)); final appView = View.of(context); final composerDockHeight = useState(0.0); final composerFocusNode = useFocusNode(); @@ -348,8 +353,7 @@ class ThreadDetailPage extends HookConsumerWidget { ); bool threadTailIsVisible() { - if (isMember && - !isArchived && + if (canReply && (!viewportHeight.isFinite || viewportHeight <= 0 || composerDockHeight.value <= 0)) { @@ -656,7 +660,7 @@ class ThreadDetailPage extends HookConsumerWidget { previousReplyCount.value = replies.length; return null; } - if (isMember && !isArchived && composerDockHeight.value <= 0) { + if (canReply && composerDockHeight.value <= 0) { return null; } if (!initialTailSettle.isComplete) { @@ -1059,7 +1063,7 @@ class ThreadDetailPage extends HookConsumerWidget { ), ], ), - if (isMember && !isArchived) + if (canReply) AndroidImeLift( child: Align( alignment: Alignment.bottomCenter, diff --git a/mobile/lib/features/channels/thread_detail_page/thread_message.dart b/mobile/lib/features/channels/thread_detail_page/thread_message.dart index 638c9934e8..7d33df5e89 100644 --- a/mobile/lib/features/channels/thread_detail_page/thread_message.dart +++ b/mobile/lib/features/channels/thread_detail_page/thread_message.dart @@ -40,6 +40,7 @@ class _ThreadMessage extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { + final startsThreads = ref.watch(channelStartsThreadsProvider(channelId)); final messageSnapshotKey = useMemoized(GlobalKey.new, const []); final swipeContentKey = useMemoized(GlobalKey.new, const []); final mediaOffset = useMessageMediaOffset(message.content, message.tags); @@ -344,7 +345,8 @@ class _ThreadMessage extends HookConsumerWidget { scaleEmojiOnly: true, mediaCarouselTrailingOverflow: Grid.gutter, - onMediaReply: allMessages == null + onMediaReply: + allMessages == null || !startsThreads ? null : () { if (!context.mounted) return; diff --git a/mobile/test/features/channels/channel_detail_page_test/swipe_reply_tests.dart b/mobile/test/features/channels/channel_detail_page_test/swipe_reply_tests.dart index b4dbac1cb7..0cad4cb158 100644 --- a/mobile/test/features/channels/channel_detail_page_test/swipe_reply_tests.dart +++ b/mobile/test/features/channels/channel_detail_page_test/swipe_reply_tests.dart @@ -1,6 +1,87 @@ part of '../channel_detail_page_test.dart'; void swipeReplyTests() { + // Solo agents answer at top level, so a Solo chat starts no threads. + Channel soloChannel() => Channel( + id: _channelId, + name: 'Solo', + channelType: 'stream', + visibility: 'private', + labels: const ['buzz.solo'], + description: '', + createdBy: 'self', + createdAt: DateTime(2026), + memberCount: 2, + isMember: true, + ); + + testWidgets('Solo chat offers no reply by swipe or message actions', ( + tester, + ) async { + final channel = soloChannel(); + final events = [ + _textMsg(id: threadIdRoot, pubkey: 'agent', content: 'Agent answer'), + ]; + await tester.pumpWidget( + _buildTestable( + signingPubkey: 'self', + messages: events, + channel: channel, + personalChat: true, + organization: Stream.value(const MeGroups()), + home: ChannelDetailPage(channel: channel), + ), + ); + await tester.pumpAndSettle(); + final row = find.byKey(const ValueKey('message-row-$threadIdRoot')); + final bubble = find + .descendant(of: row, matching: find.byType(LocalSendBubbleSurface)) + .first; + final initial = tester.getTopLeft(row); + final gesture = await tester.startGesture(tester.getCenter(bubble)); + await gesture.moveBy(const Offset(25, 0)); + await gesture.moveBy(const Offset(150, 0)); + await tester.pump(); + expect(tester.getTopLeft(row), initial); + expect(find.byKey(const ValueKey('swipe-reply-circle')), findsNothing); + await gesture.up(); + await tester.pumpAndSettle(); + expect(find.byType(ThreadDetailPage), findsNothing); + + await tester.longPress(bubble); + await tester.pumpAndSettle(); + expect(find.text('Copy link'), findsOneWidget); + expect(find.text('Reply'), findsNothing); + }); + + testWidgets('Solo thread is read-only', (tester) async { + final channel = soloChannel(); + final events = [ + _textMsg(id: threadIdRoot, pubkey: 'self', content: 'Old thread head'), + ]; + final messages = formatTimeline(events); + await tester.pumpWidget( + _buildTestable( + signingPubkey: 'self', + messages: events, + channel: channel, + personalChat: true, + organization: Stream.value(const MeGroups()), + home: ThreadDetailPage( + threadHead: messages.first, + allMessages: messages, + channelId: _channelId, + currentPubkey: 'self', + isMember: true, + isArchived: false, + ), + ), + ); + await tester.pumpAndSettle(); + expect(find.text('Old thread head'), findsOneWidget); + expect(find.byType(ComposeBar), findsNothing); + }); + for (final thread in [false, true]) { for (final own in [false, true]) { testWidgets(