fix(chat): allow empty poke messages
This commit is contained in:
@@ -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),
|
||||||
|
|||||||
Reference in New Issue
Block a user