diff --git a/apps/chanora_flutter/lib/widgets/chat_views.dart b/apps/chanora_flutter/lib/widgets/chat_views.dart index b4332d2..bdc8052 100644 --- a/apps/chanora_flutter/lib/widgets/chat_views.dart +++ b/apps/chanora_flutter/lib/widgets/chat_views.dart @@ -15,6 +15,12 @@ const double _chatSidebarTileExtent = 92; const double _chatSidebarCompactTileExtent = 76; const double _chatSidebarCompactHeight = 84; +typedef ChatMessageSender = + Future Function({ + required String message, + required rust.BridgeMessageTarget target, + }); + /// One chat/activity message shown in the chat hub. class ChatEntry { /// Construct a chat entry. @@ -497,7 +503,7 @@ String chatInputPlaceholder( case rust.BridgeMessageTarget_Client(): return 'Message $clientName...'; case rust.BridgeMessageTarget_Poke(): - return 'Poke message...'; + return 'Poke message optional...'; } } @@ -513,6 +519,16 @@ bool canSendToChatTarget( } } +bool canSendChatMessage( + rust.BridgeMessageTarget target, + BigInt? currentChannelId, + String text, +) { + if (!canSendToChatTarget(target, currentChannelId)) return false; + if (target is rust.BridgeMessageTarget_Poke) return true; + return text.trim().isNotEmpty; +} + String? chatSendBlockedReason( rust.BridgeMessageTarget target, BigInt? currentChannelId, @@ -1066,6 +1082,7 @@ class ChatDetailView extends StatefulWidget { this.messageMaxWidth, this.restoredDraft, this.onDraftChanged, + this.sendChatMessage, }); /// Chat target displayed by this detail view. @@ -1101,6 +1118,9 @@ class ChatDetailView extends StatefulWidget { /// Called with the current draft text whenever the target changes or the widget is about to be replaced. final ValueChanged? onDraftChanged; + /// Sends a chat message. Defaults to the Rust bridge send path. + final ChatMessageSender? sendChatMessage; + @override State createState() => _ChatDetailViewState(); } @@ -1168,9 +1188,12 @@ class _ChatDetailViewState extends State { void _send() { final text = _textCtl.text.trim(); - if (text.isEmpty || !_canSend) return; + if (!canSendChatMessage(widget.target, widget.currentChannelId, text)) { + return; + } _textCtl.clear(); - unawaited(rust.sendChatMessage(message: text, target: widget.target)); + final sendChatMessage = widget.sendChatMessage ?? rust.sendChatMessage; + unawaited(sendChatMessage(message: text, target: widget.target)); final ownId = widget.snapshot.ownClientId; setState(() { widget.messages.add( @@ -1223,6 +1246,9 @@ class _ChatDetailViewState extends State { channelName: widget.channelName, clientName: widget.clientName, ); + final sendTooltip = widget.target is rust.BridgeMessageTarget_Poke + ? 'Poke' + : 'Send'; return Column( children: [ @@ -1332,7 +1358,7 @@ class _ChatDetailViewState extends State { IconButton.filled( icon: const Icon(Icons.send), onPressed: _send, - tooltip: 'Send', + tooltip: sendTooltip, ), ], ), diff --git a/apps/chanora_flutter/test/services/poke_notification_service_test.dart b/apps/chanora_flutter/test/services/poke_notification_service_test.dart index d356be3..3b345a6 100644 --- a/apps/chanora_flutter/test/services/poke_notification_service_test.dart +++ b/apps/chanora_flutter/test/services/poke_notification_service_test.dart @@ -60,4 +60,22 @@ void main() { expect(specifics['groupKey'], 'chanora.pokes'); }, ); + + test('show uses fallback body for empty poke messages', () async { + final service = PokeNotificationService(); + + await service.show( + senderName: 'Alice', + message: ' ', + senderId: BigInt.from(42), + strength: rust.BridgePokeStrength.strong, + ); + + final showCall = calls.singleWhere((call) => call.method == 'show'); + final arguments = Map.from(showCall.arguments as Map); + + expect(arguments['title'], 'Poke from Alice'); + expect(arguments['body'], 'Alice pokes you'); + expect(arguments['payload'], 'poke:42'); + }); } diff --git a/apps/chanora_flutter/test/widgets/chat_views_test.dart b/apps/chanora_flutter/test/widgets/chat_views_test.dart index d81512e..b44e994 100644 --- a/apps/chanora_flutter/test/widgets/chat_views_test.dart +++ b/apps/chanora_flutter/test/widgets/chat_views_test.dart @@ -455,7 +455,7 @@ void main() { channelName: '', clientName: 'Alpha', ), - 'Poke message...', + 'Poke message optional...', ); }); @@ -737,6 +737,179 @@ void main() { refresh.dispose(); }); + test('evaluates target-aware chat message send policy', () { + final clientTarget = rust.BridgeMessageTarget.client(BigInt.from(2)); + final pokeTarget = rust.BridgeMessageTarget.poke(BigInt.from(2)); + + expect(canSendChatMessage(pokeTarget, null, ''), isTrue); + expect(canSendChatMessage(pokeTarget, null, ' '), isTrue); + expect(canSendChatMessage(pokeTarget, null, 'wake up'), isTrue); + + expect( + canSendChatMessage(const rust.BridgeMessageTarget.server(), null, ''), + isFalse, + ); + expect( + canSendChatMessage( + const rust.BridgeMessageTarget.channel(), + BigInt.from(10), + '', + ), + isFalse, + ); + expect(canSendChatMessage(clientTarget, null, ''), isFalse); + expect( + canSendChatMessage( + const rust.BridgeMessageTarget.channel(), + null, + 'hello', + ), + isFalse, + ); + expect( + canSendChatMessage( + const rust.BridgeMessageTarget.channel(), + BigInt.from(10), + 'hello', + ), + isTrue, + ); + }); + + testWidgets('poke detail sends an empty poke when the composer is empty', ( + tester, + ) async { + String? sentMessage; + rust.BridgeMessageTarget? sentTarget; + final messages = []; + final target = rust.BridgeMessageTarget.poke(BigInt.from(2)); + + await tester.pumpWidget( + MaterialApp( + localizationsDelegates: AppL10n.localizationsDelegates, + supportedLocales: AppL10n.supportedLocales, + home: Scaffold( + body: ChatDetailView( + messages: messages, + snapshot: snapshot( + channels: const [], + clients: [ + client(id: BigInt.one, name: 'Me', channelId: BigInt.zero), + ], + ), + target: target, + clientName: 'Alpha', + currentChannelId: null, + channelName: '', + sendChatMessage: ({required message, required target}) async { + sentMessage = message; + sentTarget = target; + }, + ), + ), + ), + ); + + expect(find.byTooltip('Poke'), findsOneWidget); + expect(find.byTooltip('Send'), findsNothing); + + await tester.tap(find.byTooltip('Poke')); + await tester.pump(); + + expect(sentMessage, ''); + expect(sentTarget, target); + expect(messages, hasLength(1)); + expect(messages.single.isPoke, isTrue); + expect(messages.single.message, ''); + expect(find.textContaining('You poked "Alpha"'), findsOneWidget); + expect(find.byType(CircleAvatar), findsNothing); + }); + + testWidgets('poke detail sends typed optional poke message', (tester) async { + String? sentMessage; + rust.BridgeMessageTarget? sentTarget; + final messages = []; + final target = rust.BridgeMessageTarget.poke(BigInt.from(2)); + + await tester.pumpWidget( + MaterialApp( + localizationsDelegates: AppL10n.localizationsDelegates, + supportedLocales: AppL10n.supportedLocales, + home: Scaffold( + body: ChatDetailView( + messages: messages, + snapshot: snapshot( + channels: const [], + clients: [ + client(id: BigInt.one, name: 'Me', channelId: BigInt.zero), + ], + ), + target: target, + clientName: 'Alpha', + currentChannelId: null, + channelName: '', + sendChatMessage: ({required message, required target}) async { + sentMessage = message; + sentTarget = target; + }, + ), + ), + ), + ); + + await tester.enterText(find.byType(TextField), 'wake up'); + await tester.tap(find.byTooltip('Poke')); + await tester.pump(); + + expect(sentMessage, 'wake up'); + expect(sentTarget, target); + expect(messages.single.message, 'wake up'); + expect( + find.textContaining('You poked "Alpha" with message: wake up'), + findsOneWidget, + ); + }); + + testWidgets('channel detail blocks empty sends with a joined channel', ( + tester, + ) async { + var sendCount = 0; + final messages = []; + + await tester.pumpWidget( + MaterialApp( + localizationsDelegates: AppL10n.localizationsDelegates, + supportedLocales: AppL10n.supportedLocales, + home: Scaffold( + body: ChatDetailView( + messages: messages, + snapshot: snapshot( + channels: [channel(BigInt.from(10), 'Lobby')], + clients: [ + client(id: BigInt.one, name: 'Me', channelId: BigInt.from(10)), + ], + ), + target: const rust.BridgeMessageTarget.channel(), + clientName: '', + currentChannelId: BigInt.from(10), + channelName: 'Lobby', + sendChatMessage: ({required message, required target}) async { + sendCount++; + }, + ), + ), + ), + ); + + expect(find.byTooltip('Send'), findsOneWidget); + + await tester.tap(find.byTooltip('Send')); + await tester.pump(); + + expect(sendCount, 0); + expect(messages, isEmpty); + }); + test('blocks channel chat when no voice channel is joined', () { expect( canSendToChatTarget(const rust.BridgeMessageTarget.channel(), null),