fix(chat): allow empty poke messages

This commit is contained in:
Edison Jwa
2026-06-09 01:47:23 +09:00
parent 3462de1eee
commit eca77ece81
3 changed files with 222 additions and 5 deletions
@@ -15,6 +15,12 @@ const double _chatSidebarTileExtent = 92;
const double _chatSidebarCompactTileExtent = 76; const double _chatSidebarCompactTileExtent = 76;
const double _chatSidebarCompactHeight = 84; const double _chatSidebarCompactHeight = 84;
typedef ChatMessageSender =
Future<void> Function({
required String message,
required rust.BridgeMessageTarget target,
});
/// One chat/activity message shown in the chat hub. /// One chat/activity message shown in the chat hub.
class ChatEntry { class ChatEntry {
/// Construct a chat entry. /// Construct a chat entry.
@@ -497,7 +503,7 @@ String chatInputPlaceholder(
case rust.BridgeMessageTarget_Client(): case rust.BridgeMessageTarget_Client():
return 'Message $clientName...'; return 'Message $clientName...';
case rust.BridgeMessageTarget_Poke(): 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( String? chatSendBlockedReason(
rust.BridgeMessageTarget target, rust.BridgeMessageTarget target,
BigInt? currentChannelId, BigInt? currentChannelId,
@@ -1066,6 +1082,7 @@ class ChatDetailView extends StatefulWidget {
this.messageMaxWidth, this.messageMaxWidth,
this.restoredDraft, this.restoredDraft,
this.onDraftChanged, this.onDraftChanged,
this.sendChatMessage,
}); });
/// Chat target displayed by this detail view. /// 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. /// Called with the current draft text whenever the target changes or the widget is about to be replaced.
final ValueChanged<String>? onDraftChanged; final ValueChanged<String>? onDraftChanged;
/// Sends a chat message. Defaults to the Rust bridge send path.
final ChatMessageSender? sendChatMessage;
@override @override
State<ChatDetailView> createState() => _ChatDetailViewState(); State<ChatDetailView> createState() => _ChatDetailViewState();
} }
@@ -1168,9 +1188,12 @@ class _ChatDetailViewState extends State<ChatDetailView> {
void _send() { void _send() {
final text = _textCtl.text.trim(); final text = _textCtl.text.trim();
if (text.isEmpty || !_canSend) return; if (!canSendChatMessage(widget.target, widget.currentChannelId, text)) {
return;
}
_textCtl.clear(); _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; final ownId = widget.snapshot.ownClientId;
setState(() { setState(() {
widget.messages.add( widget.messages.add(
@@ -1223,6 +1246,9 @@ class _ChatDetailViewState extends State<ChatDetailView> {
channelName: widget.channelName, channelName: widget.channelName,
clientName: widget.clientName, clientName: widget.clientName,
); );
final sendTooltip = widget.target is rust.BridgeMessageTarget_Poke
? 'Poke'
: 'Send';
return Column( return Column(
children: [ children: [
@@ -1332,7 +1358,7 @@ class _ChatDetailViewState extends State<ChatDetailView> {
IconButton.filled( IconButton.filled(
icon: const Icon(Icons.send), icon: const Icon(Icons.send),
onPressed: _send, onPressed: _send,
tooltip: 'Send', tooltip: sendTooltip,
), ),
], ],
), ),
@@ -60,4 +60,22 @@ void main() {
expect(specifics['groupKey'], 'chanora.pokes'); 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<Object?, Object?>.from(showCall.arguments as Map);
expect(arguments['title'], 'Poke from Alice');
expect(arguments['body'], 'Alice pokes you');
expect(arguments['payload'], 'poke:42');
});
} }
@@ -455,7 +455,7 @@ void main() {
channelName: '', channelName: '',
clientName: 'Alpha', clientName: 'Alpha',
), ),
'Poke message...', 'Poke message optional...',
); );
}); });
@@ -737,6 +737,179 @@ void main() {
refresh.dispose(); 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 = <ChatEntry>[];
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 = <ChatEntry>[];
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 = <ChatEntry>[];
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', () { test('blocks channel chat when no voice channel is joined', () {
expect( expect(
canSendToChatTarget(const rust.BridgeMessageTarget.channel(), null), canSendToChatTarget(const rust.BridgeMessageTarget.channel(), null),