mirror of
https://github.com/block/buzz.git
synced 2026-11-10 11:21:05 +00:00
fix(mobile): start no threads in Solo chats (#9123)
Agents in a Solo chat always answer at the chat's top level. On mobile you can still start a thread in Solo (Reply action, swipe-to-reply, media reply) or reply inside an existing one, so the agent's answer lands outside the thread you wrote in. Desktop already avoids this: Solo shows existing threads but starts none and has no thread composer. This matches desktop on mobile: - No Reply quick action, swipe-to-reply or media reply in Solo chats. - Existing Solo threads still open from their reply summary, read-only (no composer). - Every other channel is unchanged. ### Demo Synthetic Solo chat "QA demo", iPhone 17 Pro simulator. Each clip: right swipe on an agent message, then long-press for the actions. Before (main): the swipe shows the reply circle and opens a thread with a composer; Reply is in the actions. https://github.com/user-attachments/assets/cc5025a3-b7ba-4ab2-bebb-06c7b54e7906 After (this branch): the swipe does nothing; the actions have no Reply. https://github.com/user-attachments/assets/b31f9983-8c52-45c2-ae88-1661aa71210e ### Validation Added tests: a Solo chat's message neither swipes to reply nor offers Reply in its actions, and a Solo thread opens without a composer. Existing swipe-reply and Solo channel tests pass, along with the full mobile suite and analyzer. Simulator run on an iPhone 17 Pro, signed in to a real community: in an existing Solo chat, a right swipe on an agent message no longer moves or opens a thread, and the long-press actions show no Reply (Copy link, Remind me and the rest are unchanged). Team-channel Reply is covered by the existing swipe-reply tests, not this run. A second simulator run on an iPhone 17 Pro, in a synthetic Solo chat, compared main and this branch: on main the swipe opens a thread with a composer and the actions list Reply; on this branch neither does (see Demo). The Demo's "Follow thread" action still appears in a Solo chat's actions on both builds; this PR does not change it. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Signed-off-by: Tom Brow <tomb@squareup.com> Co-authored-by: Tom Brow <tomb@squareup.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -1155,6 +1155,18 @@ final channelByIdProvider = Provider.autoDispose.family<Channel?, String>(
|
||||
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<bool, String>(
|
||||
(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.
|
||||
|
||||
@@ -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 = <Widget>[
|
||||
if (messages != null)
|
||||
if (messages != null && startsThreads)
|
||||
_MessageQuickActionTile(
|
||||
icon: BuzzIcons.messageSquareReply,
|
||||
label: 'Reply',
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user