diff --git a/assets/l10n/app_en.arb b/assets/l10n/app_en.arb index 9b808a5a25..b9aad60bc9 100644 --- a/assets/l10n/app_en.arb +++ b/assets/l10n/app_en.arb @@ -203,6 +203,10 @@ "user": {"type": "String", "example": "channel name"} } }, + "composeBoxDeactivatedDmContentHint": "You cannot send messages to deactivated users.", + "@composeBoxDeactivatedDmContentHint": { + "description": "Hint text for content input when sending a message to one or multiple deactivated persons." + }, "composeBoxGroupDmContentHint": "Message group", "@composeBoxGroupDmContentHint": { "description": "Hint text for content input when sending a message to a group." diff --git a/lib/widgets/compose_box.dart b/lib/widgets/compose_box.dart index c80c5dd26a..5995ea205d 100644 --- a/lib/widgets/compose_box.dart +++ b/lib/widgets/compose_box.dart @@ -271,17 +271,18 @@ class _ContentInput extends StatelessWidget { required this.controller, required this.focusNode, required this.hintText, + this.enabled = true, }); final Narrow narrow; final ComposeContentController controller; final FocusNode focusNode; final String hintText; + final bool enabled; @override Widget build(BuildContext context) { ColorScheme colorScheme = Theme.of(context).colorScheme; - return InputDecorator( decoration: const InputDecoration(), child: ConstrainedBox( @@ -303,6 +304,7 @@ class _ContentInput extends StatelessWidget { decoration: InputDecoration.collapsed(hintText: hintText), maxLines: null, textCapitalization: TextCapitalization.sentences, + enabled: enabled, ); }), )); @@ -377,32 +379,37 @@ class _FixedDestinationContentInput extends StatelessWidget { required this.narrow, required this.controller, required this.focusNode, + required this.enabled, }); final SendableNarrow narrow; final ComposeContentController controller; final FocusNode focusNode; + final bool enabled; String _hintText(BuildContext context) { final zulipLocalizations = ZulipLocalizations.of(context); - switch (narrow) { - case TopicNarrow(:final streamId, :final topic): + switch ((narrow, enabled)) { + case (TopicNarrow(:final streamId, :final topic), _): final store = PerAccountStoreWidget.of(context); final streamName = store.streams[streamId]?.name ?? zulipLocalizations.composeBoxUnknownChannelName; return zulipLocalizations.composeBoxChannelContentHint(streamName, topic); - case DmNarrow(otherRecipientIds: []): // The self-1:1 thread. + case (DmNarrow(otherRecipientIds: []), _): // The self-1:1 thread. return zulipLocalizations.composeBoxSelfDmContentHint; - case DmNarrow(otherRecipientIds: [final otherUserId]): + case (DmNarrow(otherRecipientIds: [final otherUserId]), true): final store = PerAccountStoreWidget.of(context); final fullName = store.users[otherUserId]?.fullName; if (fullName == null) return zulipLocalizations.composeBoxGenericContentHint; return zulipLocalizations.composeBoxDmContentHint(fullName); - case DmNarrow(): // A group DM thread. + case (DmNarrow(), true): // A group DM thread. return zulipLocalizations.composeBoxGroupDmContentHint; + + case (DmNarrow(), false): + return zulipLocalizations.composeBoxDeactivatedDmContentHint; } } @@ -412,7 +419,8 @@ class _FixedDestinationContentInput extends StatelessWidget { narrow: narrow, controller: controller, focusNode: focusNode, - hintText: _hintText(context)); + hintText: _hintText(context), + enabled: enabled); } } @@ -492,10 +500,15 @@ Future _uploadFiles({ } abstract class _AttachUploadsButton extends StatelessWidget { - const _AttachUploadsButton({required this.contentController, required this.contentFocusNode}); + const _AttachUploadsButton({ + required this.contentController, + required this.contentFocusNode, + required this.enabled, + }); final ComposeContentController contentController; final FocusNode contentFocusNode; + final bool enabled; IconData get icon; String tooltip(ZulipLocalizations zulipLocalizations); @@ -534,7 +547,7 @@ abstract class _AttachUploadsButton extends StatelessWidget { return IconButton( icon: Icon(icon), tooltip: tooltip(zulipLocalizations), - onPressed: () => _handlePress(context)); + onPressed: enabled ? () => _handlePress(context) : null); } } @@ -578,7 +591,11 @@ Future> _getFilePickerFiles(BuildContext context, FileType type) } class _AttachFileButton extends _AttachUploadsButton { - const _AttachFileButton({required super.contentController, required super.contentFocusNode}); + const _AttachFileButton({ + required super.contentController, + required super.contentFocusNode, + required super.enabled, + }); @override IconData get icon => Icons.attach_file; @@ -594,7 +611,11 @@ class _AttachFileButton extends _AttachUploadsButton { } class _AttachMediaButton extends _AttachUploadsButton { - const _AttachMediaButton({required super.contentController, required super.contentFocusNode}); + const _AttachMediaButton({ + required super.contentController, + required super.contentFocusNode, + required super.enabled, + }); @override IconData get icon => Icons.image; @@ -611,7 +632,11 @@ class _AttachMediaButton extends _AttachUploadsButton { } class _AttachFromCameraButton extends _AttachUploadsButton { - const _AttachFromCameraButton({required super.contentController, required super.contentFocusNode}); + const _AttachFromCameraButton({ + required super.contentController, + required super.contentFocusNode, + required super.enabled, + }); @override IconData get icon => Icons.camera_alt; @@ -667,11 +692,13 @@ class _SendButton extends StatefulWidget { required this.topicController, required this.contentController, required this.getDestination, + this.enabled = true, }); final ComposeTopicController? topicController; final ComposeContentController contentController; final MessageDestination Function() getDestination; + final bool enabled; @override State<_SendButton> createState() => _SendButtonState(); @@ -774,7 +801,7 @@ class _SendButtonState extends State<_SendButton> { ), color: foregroundColor, icon: const Icon(Icons.send), - onPressed: _send)); + onPressed: widget.enabled ? _send : null)); } } @@ -785,6 +812,7 @@ class _ComposeBoxLayout extends StatelessWidget { required this.sendButton, required this.contentController, required this.contentFocusNode, + this.enabled = true, }); final Widget? topicInput; @@ -792,6 +820,7 @@ class _ComposeBoxLayout extends StatelessWidget { final Widget sendButton; final ComposeContentController contentController; final FocusNode contentFocusNode; + final bool enabled; @override Widget build(BuildContext context) { @@ -835,9 +864,21 @@ class _ComposeBoxLayout extends StatelessWidget { data: themeData.copyWith( iconTheme: themeData.iconTheme.copyWith(color: colorScheme.onSurfaceVariant)), child: Row(children: [ - _AttachFileButton(contentController: contentController, contentFocusNode: contentFocusNode), - _AttachMediaButton(contentController: contentController, contentFocusNode: contentFocusNode), - _AttachFromCameraButton(contentController: contentController, contentFocusNode: contentFocusNode), + _AttachFileButton( + contentController: contentController, + contentFocusNode: contentFocusNode, + enabled: enabled, + ), + _AttachMediaButton( + contentController: contentController, + contentFocusNode: contentFocusNode, + enabled: enabled, + ), + _AttachFromCameraButton( + contentController: contentController, + contentFocusNode: contentFocusNode, + enabled: enabled, + ), ])), ])))); } } @@ -925,6 +966,20 @@ class _FixedDestinationComposeBoxState extends State<_FixedDestinationComposeBox @override FocusNode get contentFocusNode => _contentFocusNode; final _contentFocusNode = FocusNode(); + late bool enabled; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + final store = PerAccountStoreWidget.of(context); + enabled = switch (widget.narrow) { + DmNarrow(:final otherRecipientIds) => otherRecipientIds.every((id) => + store.users[id]?.isActive ?? true), + TopicNarrow() => true, + }; + + if (!enabled) _contentController.clear(); + } @override void dispose() { @@ -939,15 +994,18 @@ class _FixedDestinationComposeBoxState extends State<_FixedDestinationComposeBox contentController: _contentController, contentFocusNode: _contentFocusNode, topicInput: null, + enabled: enabled, contentInput: _FixedDestinationContentInput( narrow: widget.narrow, controller: _contentController, focusNode: _contentFocusNode, + enabled: enabled, ), sendButton: _SendButton( topicController: null, contentController: _contentController, getDestination: () => widget.narrow.destination, + enabled: enabled, )); } } diff --git a/test/widgets/message_list_test.dart b/test/widgets/message_list_test.dart index bf14b874c0..1f80c03ca2 100644 --- a/test/widgets/message_list_test.dart +++ b/test/widgets/message_list_test.dart @@ -15,6 +15,7 @@ import 'package:zulip/api/route/messages.dart'; import 'package:zulip/model/localizations.dart'; import 'package:zulip/model/narrow.dart'; import 'package:zulip/model/store.dart'; +import 'package:zulip/widgets/compose_box.dart'; import 'package:zulip/widgets/content.dart'; import 'package:zulip/widgets/emoji_reaction.dart'; import 'package:zulip/widgets/icons.dart'; @@ -35,6 +36,7 @@ import '../stdlib_checks.dart'; import '../test_images.dart'; import 'content_checks.dart'; import 'dialog_checks.dart'; +import 'text_field_checks.dart'; void main() { TestZulipBinding.ensureInitialized(); @@ -1144,4 +1146,222 @@ void main() { }); }); }); + + group('message list page for DMs with deactivated users', () { + TextField findContentField(WidgetTester tester) => tester.widget( + find.descendant(of: find.byType(ComposeBox), + matching: find.byType(TextField))); + + void checkIconButton(WidgetTester tester, IconData icon, {required bool enabled}) { + final button = tester.widget(find.descendant( + of: find.byType(ComposeBox), + matching: find.widgetWithIcon(IconButton, icon), + )); + enabled ? check(button.onPressed).isNotNull() : check(button.onPressed).isNull(); + } + + void checkComposeBox(WidgetTester tester, List recipients, { + required bool enabled, + }) { + assert(recipients.isNotEmpty, 'DMs should have at least one recipient.'); + + final zulipLocalizations = GlobalLocalizations.zulipLocalizations; + final expectedHintText = enabled + ? recipients.length == 1 + ? zulipLocalizations.composeBoxDmContentHint(recipients.first.fullName) + : zulipLocalizations.composeBoxGroupDmContentHint + : zulipLocalizations.composeBoxDeactivatedDmContentHint; + check(findContentField(tester)) + ..enabled.equals(enabled) + ..hintText.equals(expectedHintText) + ..text.equals(''); + + checkIconButton(tester, Icons.send, enabled: enabled); + checkIconButton(tester, Icons.attach_file, enabled: enabled); + checkIconButton(tester, Icons.image, enabled: enabled); + checkIconButton(tester, Icons.camera_alt, enabled: enabled); + } + + group('1:1 DMs with a deactivated user', () { + testWidgets('all parts of the compose box are disabled', (tester) async { + final selfUser = eg.selfUser; + final deactivatedUser = eg.user(isActive: false); + final narrow = DmNarrow.withUser(deactivatedUser.userId, selfUserId: selfUser.userId); + final messages = List.generate(5, (index) => eg.dmMessage( + from: selfUser, to: [deactivatedUser])); + + await setupMessageListPage(tester, narrow: narrow, messages: messages, + users: [deactivatedUser]); + + checkComposeBox(tester, [deactivatedUser], enabled: false); + }); + + group('active user becomes deactivated -> compose box is disabled', () { + testWidgets('compose box isn\'t populated with text', (tester) async { + final selfUser = eg.selfUser; + final activeUser = eg.user(isActive: true); + final narrow = DmNarrow.withUser(activeUser.userId, selfUserId: selfUser.userId); + final messages = List.generate(5, (index) => eg.dmMessage( + from: selfUser, to: [activeUser])); + + await setupMessageListPage(tester, narrow: narrow, messages: messages, + users: [activeUser]); + + checkComposeBox(tester, [activeUser], enabled: true); + + await store.handleEvent(RealmUserUpdateEvent(id: 1, + userId: activeUser.userId, isActive: false)); + await tester.pump(); + + checkComposeBox(tester, [activeUser], enabled: false); + }); + + testWidgets('compose box is populated with text -> text is replaced by hint text', (tester) async { + final selfUser = eg.selfUser; + final activeUser = eg.user(isActive: true); + final narrow = DmNarrow.withUser(activeUser.userId, selfUserId: selfUser.userId); + final messages = List.generate(5, (index) => eg.dmMessage( + from: selfUser, to: [activeUser])); + + await setupMessageListPage(tester, narrow: narrow, messages: messages, + users: [activeUser]); + + checkComposeBox(tester, [activeUser], enabled: true); + + await tester.enterText(find.byWidget(findContentField(tester)), + 'some text'); + + await store.handleEvent(RealmUserUpdateEvent(id: 1, + userId: activeUser.userId, isActive: false)); + await tester.pump(); + + checkComposeBox(tester, [activeUser], enabled: false); + }); + }); + + testWidgets('deactivated user becomes active -> compose box is enabled', (tester) async { + final selfUser = eg.selfUser; + final deactivatedUser = eg.user(isActive: false); + final narrow = DmNarrow.withUser(deactivatedUser.userId, selfUserId: selfUser.userId); + final messages = List.generate(5, (index) => eg.dmMessage( + from: selfUser, to: [deactivatedUser])); + + await setupMessageListPage(tester, narrow: narrow, messages: messages, + users: [deactivatedUser]); + + checkComposeBox(tester, [deactivatedUser], enabled: false); + + await store.handleEvent(RealmUserUpdateEvent(id: 1, + userId: deactivatedUser.userId, isActive: true)); + await tester.pump(); + + checkComposeBox(tester, [deactivatedUser], enabled: true); + }); + }); + + group('group DMs with deactivated users', () { + testWidgets('all parts of the compose box are disabled', (tester) async { + final selfUser = eg.selfUser; + final deactivatedUsers = [ + eg.user(userId: 1, isActive: false), + eg.user(userId: 2, isActive: false), + ]; + final narrow = DmNarrow(allRecipientIds: [ + ...deactivatedUsers.map((user) => user.userId), + selfUser.userId + ]..sort(), selfUserId: selfUser.userId); + final messages = List.generate(5, (index) => eg.dmMessage( + from: selfUser, to: deactivatedUsers)); + + await setupMessageListPage(tester, narrow: narrow, messages: messages, + users: deactivatedUsers); + + checkComposeBox(tester, deactivatedUsers, enabled: false); + }); + + group('at least one user becomes deactivated -> compose box is disabled', () { + testWidgets('compose box isn\'t populated with text', (tester) async { + final selfUser = eg.selfUser; + final activeUsers = [ + eg.user(userId: 1, isActive: true), + eg.user(userId: 2, isActive: true), + ]; + final narrow = DmNarrow(allRecipientIds: [ + ...activeUsers.map((user) => user.userId), + selfUser.userId, + ]..sort(), selfUserId: selfUser.userId); + final messages = List.generate(5, (index) => eg.dmMessage( + from: selfUser, to: activeUsers)); + + await setupMessageListPage(tester, narrow: narrow, messages: messages, + users: activeUsers); + + checkComposeBox(tester, activeUsers, enabled: true); + + await store.handleEvent(RealmUserUpdateEvent(id: 1, + userId: activeUsers[0].userId, isActive: false)); + await tester.pump(); + + checkComposeBox(tester, activeUsers, enabled: false); + }); + + testWidgets('compose box is populated with text -> text is replaced by hint text', (tester) async { + final selfUser = eg.selfUser; + final activeUsers = [ + eg.user(userId: 1, isActive: true), + eg.user(userId: 2, isActive: true), + ]; + final narrow = DmNarrow(allRecipientIds: [ + ...activeUsers.map((user) => user.userId), + selfUser.userId, + ]..sort(), selfUserId: selfUser.userId); + final messages = List.generate(5, (index) => eg.dmMessage( + from: selfUser, to: activeUsers)); + + await setupMessageListPage(tester, narrow: narrow, messages: messages, + users: activeUsers); + + checkComposeBox(tester, activeUsers, enabled: true); + + await tester.enterText(find.byWidget(findContentField(tester)), + 'some text'); + + await store.handleEvent(RealmUserUpdateEvent(id: 1, + userId: activeUsers[0].userId, isActive: false)); + await tester.pump(); + + checkComposeBox(tester, activeUsers, enabled: false); + }); + }); + + testWidgets('all deactivated users become active -> compose box is enabled', (tester) async { + final selfUser = eg.selfUser; + final deactivatedUsers = [ + eg.user(userId: 1, isActive: false), + eg.user(userId: 2, isActive: false), + ]; + final narrow = DmNarrow(allRecipientIds: [ + ...deactivatedUsers.map((user) => user.userId), + selfUser.userId, + ]..sort(), selfUserId: selfUser.userId); + final messages = List.generate(5, (index) => eg.dmMessage( + from: selfUser, to: deactivatedUsers)); + + await setupMessageListPage(tester, narrow: narrow, messages: messages, + users: deactivatedUsers); + + checkComposeBox(tester, deactivatedUsers, enabled: false); + + await store.handleEvent(RealmUserUpdateEvent(id: 1, + userId: deactivatedUsers[0].userId, isActive: true)); + await tester.pump(); + checkComposeBox(tester, deactivatedUsers, enabled: false); + + await store.handleEvent(RealmUserUpdateEvent(id: 2, + userId: deactivatedUsers[1].userId, isActive: true)); + await tester.pump(); + checkComposeBox(tester, deactivatedUsers, enabled: true); + }); + }); + }); } diff --git a/test/widgets/text_field_checks.dart b/test/widgets/text_field_checks.dart new file mode 100644 index 0000000000..4d69f21762 --- /dev/null +++ b/test/widgets/text_field_checks.dart @@ -0,0 +1,8 @@ +import 'package:checks/checks.dart'; +import 'package:flutter/material.dart'; + +extension TextFieldChecks on Subject { + Subject get text => has((tf) => tf.controller?.text, 'text'); + Subject get hintText => has((tf) => tf.decoration?.hintText, 'hintText'); + Subject get enabled => has((tf) => tf.enabled, 'enabled'); +}