diff --git a/assets/l10n/app_en.arb b/assets/l10n/app_en.arb index e1beadac31..c4e9d6e5c6 100644 --- a/assets/l10n/app_en.arb +++ b/assets/l10n/app_en.arb @@ -180,6 +180,10 @@ "@successMessageLinkCopied": { "description": "Message when link of a message was copied to the user's system clipboard." }, + "errorBannerDeactivatedDmLabel": "You cannot send messages to deactivated users.", + "@errorBannerDeactivatedDmLabel": { + "description": "Label text for error banner when sending a message to one or multiple deactivated users." + }, "composeBoxAttachFilesTooltip": "Attach files", "@composeBoxAttachFilesTooltip": { "description": "Tooltip for compose box icon to attach a file to the message." diff --git a/lib/api/model/events.dart b/lib/api/model/events.dart index a1b6aa011c..8dc395a8da 100644 --- a/lib/api/model/events.dart +++ b/lib/api/model/events.dart @@ -286,6 +286,7 @@ class RealmUserUpdateEvent extends RealmUserEvent { @JsonKey(readValue: _readFromPerson) final RealmUserUpdateCustomProfileField? customProfileField; @JsonKey(readValue: _readFromPerson) final String? newEmail; + @JsonKey(readValue: _readFromPerson) final bool? isActive; static Object? _readFromPerson(Map json, String key) { return (json['person'] as Map)[key]; @@ -319,6 +320,7 @@ class RealmUserUpdateEvent extends RealmUserEvent { this.deliveryEmail, this.customProfileField, this.newEmail, + this.isActive, }); factory RealmUserUpdateEvent.fromJson(Map json) => diff --git a/lib/api/model/events.g.dart b/lib/api/model/events.g.dart index 54fee1a5ff..dc988b45cb 100644 --- a/lib/api/model/events.g.dart +++ b/lib/api/model/events.g.dart @@ -151,6 +151,8 @@ RealmUserUpdateEvent _$RealmUserUpdateEventFromJson( as Map), newEmail: RealmUserUpdateEvent._readFromPerson(json, 'new_email') as String?, + isActive: + RealmUserUpdateEvent._readFromPerson(json, 'is_active') as bool?, ); Map _$RealmUserUpdateEventToJson( @@ -172,6 +174,7 @@ Map _$RealmUserUpdateEventToJson( const NullableStringJsonConverter().toJson), 'custom_profile_field': instance.customProfileField, 'new_email': instance.newEmail, + 'is_active': instance.isActive, }; const _$UserRoleEnumMap = { diff --git a/lib/model/store.dart b/lib/model/store.dart index 2fa3add040..94653b11bf 100644 --- a/lib/model/store.dart +++ b/lib/model/store.dart @@ -461,6 +461,7 @@ class PerAccountStore extends ChangeNotifier with ChannelStore, MessageStore { if (event.isBillingAdmin != null) user.isBillingAdmin = event.isBillingAdmin!; if (event.deliveryEmail != null) user.deliveryEmail = event.deliveryEmail!.value; if (event.newEmail != null) user.email = event.newEmail!; + if (event.isActive != null) user.isActive = event.isActive!; if (event.customProfileField != null) { final profileData = (user.profileData ??= {}); final update = event.customProfileField!; diff --git a/lib/widgets/compose_box.dart b/lib/widgets/compose_box.dart index 557edba588..1294fc787f 100644 --- a/lib/widgets/compose_box.dart +++ b/lib/widgets/compose_box.dart @@ -16,6 +16,7 @@ import '../model/store.dart'; import 'autocomplete.dart'; import 'dialog.dart'; import 'store.dart'; +import 'theme.dart'; const double _inputVerticalPadding = 8; const double _sendButtonSize = 36; @@ -850,11 +851,13 @@ class _ComposeBoxLayout extends StatelessWidget { required this.sendButton, required this.contentController, required this.contentFocusNode, + this.blockingErrorBanner, }); final Widget? topicInput; final Widget contentInput; final Widget sendButton; + final Widget? blockingErrorBanner; final ComposeContentController contentController; final FocusNode contentFocusNode; @@ -883,28 +886,30 @@ class _ComposeBoxLayout extends StatelessWidget { minimum: const EdgeInsets.fromLTRB(8, 0, 8, 8), child: Padding( padding: const EdgeInsets.only(top: 8.0), - child: Column(children: [ - Row(crossAxisAlignment: CrossAxisAlignment.end, children: [ - Expanded( - child: Theme( - data: inputThemeData, - child: Column(children: [ - if (topicInput != null) topicInput!, - if (topicInput != null) const SizedBox(height: 8), - contentInput, - ]))), - const SizedBox(width: 8), - sendButton, - ]), - Theme( - 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), - ])), - ])))); } + child: blockingErrorBanner != null + ? SizedBox(width: double.infinity, child: blockingErrorBanner) + : Column(children: [ + Row(crossAxisAlignment: CrossAxisAlignment.end, children: [ + Expanded( + child: Theme( + data: inputThemeData, + child: Column(children: [ + if (topicInput != null) topicInput!, + if (topicInput != null) const SizedBox(height: 8), + contentInput, + ]))), + const SizedBox(width: 8), + sendButton, + ]), + Theme( + 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), + ])), + ])))); } } abstract class ComposeBoxController extends State { @@ -973,6 +978,27 @@ class _StreamComposeBoxState extends State<_StreamComposeBox> implements Compose } } +class _ErrorBanner extends StatelessWidget { + const _ErrorBanner({required this.label}); + + final String label; + + @override + Widget build(BuildContext context) { + final designVariables = DesignVariables.of(context); + return Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: designVariables.errorBannerBackground, + border: Border.all(color: designVariables.errorBannerBorder), + borderRadius: BorderRadius.circular(5)), + child: Text(label, + style: TextStyle(fontSize: 18, color: designVariables.errorBannerLabel), + ), + ); + } +} + class _FixedDestinationComposeBox extends StatefulWidget { const _FixedDestinationComposeBox({super.key, required this.narrow}); @@ -998,6 +1024,19 @@ class _FixedDestinationComposeBoxState extends State<_FixedDestinationComposeBox super.dispose(); } + Widget? _errorBanner(BuildContext context) { + if (widget.narrow case DmNarrow(:final otherRecipientIds)) { + final store = PerAccountStoreWidget.of(context); + final hasDeactivatedUser = otherRecipientIds.any((id) => + !(store.users[id]?.isActive ?? true)); + if (hasDeactivatedUser) { + return _ErrorBanner(label: ZulipLocalizations.of(context) + .errorBannerDeactivatedDmLabel); + } + } + return null; + } + @override Widget build(BuildContext context) { return _ComposeBoxLayout( @@ -1013,7 +1052,8 @@ class _FixedDestinationComposeBoxState extends State<_FixedDestinationComposeBox topicController: null, contentController: _contentController, getDestination: () => widget.narrow.destination, - )); + ), + blockingErrorBanner: _errorBanner(context)); } } diff --git a/lib/widgets/theme.dart b/lib/widgets/theme.dart index da83f92ff5..8a1439c380 100644 --- a/lib/widgets/theme.dart +++ b/lib/widgets/theme.dart @@ -141,6 +141,9 @@ class DesignVariables extends ThemeExtension { channelColorSwatches: ChannelColorSwatches.light, atMentionMarker: const HSLColor.fromAHSL(0.5, 0, 0, 0.2).toColor(), dmHeaderBg: const HSLColor.fromAHSL(1, 46, 0.35, 0.93).toColor(), + errorBannerBackground: const HSLColor.fromAHSL(1, 4, 0.33, 0.90).toColor(), + errorBannerBorder: const HSLColor.fromAHSL(0.4, 3, 0.57, 0.33).toColor(), + errorBannerLabel: const HSLColor.fromAHSL(1, 4, 0.58, 0.33).toColor(), loginOrDivider: const Color(0xffdedede), loginOrDividerText: const Color(0xff575757), sectionCollapseIcon: const Color(0x7f1e2e48), @@ -163,6 +166,9 @@ class DesignVariables extends ThemeExtension { // TODO(#95) need proper dark-theme color (this is ad hoc) atMentionMarker: const HSLColor.fromAHSL(0.4, 0, 0, 1).toColor(), dmHeaderBg: const HSLColor.fromAHSL(1, 46, 0.15, 0.2).toColor(), + errorBannerBackground: const HSLColor.fromAHSL(1, 0, 0.61, 0.19).toColor(), + errorBannerBorder: const HSLColor.fromAHSL(0.4, 3, 0.73, 0.74).toColor(), + errorBannerLabel: const HSLColor.fromAHSL(1, 2, 0.73, 0.80).toColor(), loginOrDivider: const Color(0xff424242), loginOrDividerText: const Color(0xffa8a8a8), // TODO(#95) need proper dark-theme color (this is ad hoc) @@ -185,6 +191,9 @@ class DesignVariables extends ThemeExtension { required this.channelColorSwatches, required this.atMentionMarker, required this.dmHeaderBg, + required this.errorBannerBackground, + required this.errorBannerBorder, + required this.errorBannerLabel, required this.loginOrDivider, required this.loginOrDividerText, required this.sectionCollapseIcon, @@ -218,6 +227,9 @@ class DesignVariables extends ThemeExtension { // Not named variables in Figma; taken from older Figma drafts, or elsewhere. final Color atMentionMarker; final Color dmHeaderBg; + final Color errorBannerBackground; + final Color errorBannerBorder; + final Color errorBannerLabel; final Color loginOrDivider; // TODO(#95) need proper dark-theme color (this is ad hoc) final Color loginOrDividerText; // TODO(#95) need proper dark-theme color (this is ad hoc) final Color sectionCollapseIcon; @@ -238,6 +250,9 @@ class DesignVariables extends ThemeExtension { ChannelColorSwatches? channelColorSwatches, Color? atMentionMarker, Color? dmHeaderBg, + Color? errorBannerBackground, + Color? errorBannerBorder, + Color? errorBannerLabel, Color? loginOrDivider, Color? loginOrDividerText, Color? sectionCollapseIcon, @@ -257,6 +272,9 @@ class DesignVariables extends ThemeExtension { channelColorSwatches: channelColorSwatches ?? this.channelColorSwatches, atMentionMarker: atMentionMarker ?? this.atMentionMarker, dmHeaderBg: dmHeaderBg ?? this.dmHeaderBg, + errorBannerBackground: errorBannerBackground ?? this.errorBannerBackground, + errorBannerBorder: errorBannerBorder ?? this.errorBannerBorder, + errorBannerLabel: errorBannerLabel ?? this.errorBannerLabel, loginOrDivider: loginOrDivider ?? this.loginOrDivider, loginOrDividerText: loginOrDividerText ?? this.loginOrDividerText, sectionCollapseIcon: sectionCollapseIcon ?? this.sectionCollapseIcon, @@ -283,6 +301,9 @@ class DesignVariables extends ThemeExtension { channelColorSwatches: ChannelColorSwatches.lerp(channelColorSwatches, other.channelColorSwatches, t), atMentionMarker: Color.lerp(atMentionMarker, other.atMentionMarker, t)!, dmHeaderBg: Color.lerp(dmHeaderBg, other.dmHeaderBg, t)!, + errorBannerBackground: Color.lerp(errorBannerBackground, other.errorBannerBackground, t)!, + errorBannerBorder: Color.lerp(errorBannerBorder, other.errorBannerBorder, t)!, + errorBannerLabel: Color.lerp(errorBannerLabel, other.errorBannerLabel, t)!, loginOrDivider: Color.lerp(loginOrDivider, other.loginOrDivider, t)!, loginOrDividerText: Color.lerp(loginOrDividerText, other.loginOrDividerText, t)!, sectionCollapseIcon: Color.lerp(sectionCollapseIcon, other.sectionCollapseIcon, t)!, diff --git a/test/widgets/compose_box_test.dart b/test/widgets/compose_box_test.dart index 8152cd5c40..1a1d677bbd 100644 --- a/test/widgets/compose_box_test.dart +++ b/test/widgets/compose_box_test.dart @@ -6,6 +6,8 @@ import 'package:http/http.dart' as http; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:image_picker/image_picker.dart'; +import 'package:zulip/api/model/events.dart'; +import 'package:zulip/api/model/model.dart'; import 'package:zulip/api/route/messages.dart'; import 'package:zulip/model/localizations.dart'; import 'package:zulip/model/narrow.dart'; @@ -16,6 +18,7 @@ import '../api/fake_api.dart'; import '../example_data.dart' as eg; import '../flutter_checks.dart'; import '../model/binding.dart'; +import '../model/test_store.dart'; import '../stdlib_checks.dart'; import 'dialog_checks.dart'; import 'test_app.dart'; @@ -26,11 +29,14 @@ void main() { late PerAccountStore store; late FakeApiConnection connection; - Future> prepareComposeBox(WidgetTester tester, Narrow narrow) async { + Future> prepareComposeBox(WidgetTester tester, + {required Narrow narrow, List users = const []}) async { addTearDown(testBinding.reset); await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot()); store = await testBinding.globalStore.perAccount(eg.selfAccount.id); + + await store.addUsers([eg.selfUser, ...users]); connection = store.connection as FakeApiConnection; final controllerKey = GlobalKey(); @@ -169,14 +175,14 @@ void main() { testWidgets('_StreamComposeBox', (tester) async { final key = await prepareComposeBox(tester, - ChannelNarrow(eg.stream().streamId)); + narrow: ChannelNarrow(eg.stream().streamId)); checkComposeBoxTextFields(tester, controllerKey: key, expectTopicTextField: true); }); testWidgets('_FixedDestinationComposeBox', (tester) async { final key = await prepareComposeBox(tester, - TopicNarrow.ofMessage(eg.streamMessage())); + narrow: TopicNarrow.ofMessage(eg.streamMessage())); checkComposeBoxTextFields(tester, controllerKey: key, expectTopicTextField: false); }); @@ -187,7 +193,7 @@ void main() { required void Function(int messageId) prepareResponse, }) async { final zulipLocalizations = GlobalLocalizations.zulipLocalizations; - await prepareComposeBox(tester, const TopicNarrow(123, 'some topic')); + await prepareComposeBox(tester, narrow: const TopicNarrow(123, 'some topic')); final contentInputFinder = find.byWidgetPredicate( (widget) => widget is TextField && widget.controller is ComposeContentController); @@ -251,7 +257,7 @@ void main() { group('attach from media library', () { testWidgets('success', (tester) async { - final controllerKey = await prepareComposeBox(tester, ChannelNarrow(eg.stream().streamId)); + final controllerKey = await prepareComposeBox(tester, narrow: ChannelNarrow(eg.stream().streamId)); final composeBoxController = controllerKey.currentState!; // (When we check that the send button looks disabled, it should be because @@ -307,7 +313,7 @@ void main() { group('attach from camera', () { testWidgets('success', (tester) async { - final controllerKey = await prepareComposeBox(tester, ChannelNarrow(eg.stream().streamId)); + final controllerKey = await prepareComposeBox(tester, narrow: ChannelNarrow(eg.stream().streamId)); final composeBoxController = controllerKey.currentState!; // (When we check that the send button looks disabled, it should be because @@ -361,4 +367,110 @@ void main() { // TODO test what happens when capturing/uploading fails }); }); + + group('compose box in DMs with deactivated users', () { + Finder contentFieldFinder() => find.descendant( + of: find.byType(ComposeBox), + matching: find.byType(TextField)); + + Finder attachButtonFinder(IconData icon) => find.descendant( + of: find.byType(ComposeBox), + matching: find.widgetWithIcon(IconButton, icon)); + + void checkComposeBoxParts({required bool areShown}) { + check(contentFieldFinder().evaluate().length).equals(areShown ? 1 : 0); + check(attachButtonFinder(Icons.attach_file).evaluate().length).equals(areShown ? 1 : 0); + check(attachButtonFinder(Icons.image).evaluate().length).equals(areShown ? 1 : 0); + check(attachButtonFinder(Icons.camera_alt).evaluate().length).equals(areShown ? 1 : 0); + } + + void checkBanner({required bool isShown}) { + final bannerTextFinder = find.text(GlobalLocalizations.zulipLocalizations + .errorBannerDeactivatedDmLabel); + check(bannerTextFinder.evaluate().length).equals(isShown ? 1 : 0); + } + + void checkComposeBox({required bool isShown}) { + checkComposeBoxParts(areShown: isShown); + checkBanner(isShown: !isShown); + } + + Future changeUserStatus(WidgetTester tester, + {required User user, required bool isActive}) async { + await store.handleEvent(RealmUserUpdateEvent(id: 1, + userId: user.userId, isActive: isActive)); + await tester.pump(); + } + + DmNarrow dmNarrowWith(User otherUser) => DmNarrow.withUser(otherUser.userId, + selfUserId: eg.selfUser.userId); + + DmNarrow groupDmNarrowWith(List otherUsers) => DmNarrow.withOtherUsers( + otherUsers.map((u) => u.userId), selfUserId: eg.selfUser.userId); + + group('1:1 DMs', () { + testWidgets('compose box replaced with a banner', (tester) async { + final deactivatedUser = eg.user(isActive: false); + await prepareComposeBox(tester, narrow: dmNarrowWith(deactivatedUser), + users: [deactivatedUser]); + checkComposeBox(isShown: false); + }); + + testWidgets('active user becomes deactivated -> ' + 'compose box is replaced with a banner', (tester) async { + final activeUser = eg.user(isActive: true); + await prepareComposeBox(tester, narrow: dmNarrowWith(activeUser), + users: [activeUser]); + checkComposeBox(isShown: true); + + await changeUserStatus(tester, user: activeUser, isActive: false); + checkComposeBox(isShown: false); + }); + + testWidgets('deactivated user becomes active -> ' + 'banner is replaced with the compose box', (tester) async { + final deactivatedUser = eg.user(isActive: false); + await prepareComposeBox(tester, narrow: dmNarrowWith(deactivatedUser), + users: [deactivatedUser]); + checkComposeBox(isShown: false); + + await changeUserStatus(tester, user: deactivatedUser, isActive: true); + checkComposeBox(isShown: true); + }); + }); + + group('group DMs', () { + testWidgets('compose box replaced with a banner', (tester) async { + final deactivatedUsers = [eg.user(isActive: false), eg.user(isActive: false)]; + await prepareComposeBox(tester, narrow: groupDmNarrowWith(deactivatedUsers), + users: deactivatedUsers); + checkComposeBox(isShown: false); + }); + + testWidgets('at least one user becomes deactivated -> ' + 'compose box is replaced with a banner', (tester) async { + final activeUsers = [eg.user(isActive: true), eg.user(isActive: true)]; + await prepareComposeBox(tester, narrow: groupDmNarrowWith(activeUsers), + users: activeUsers); + checkComposeBox(isShown: true); + + await changeUserStatus(tester, user: activeUsers[0], isActive: false); + checkComposeBox(isShown: false); + }); + + testWidgets('all deactivated users become active -> ' + 'banner is replaced with the compose box', (tester) async { + final deactivatedUsers = [eg.user(isActive: false), eg.user(isActive: false)]; + await prepareComposeBox(tester, narrow: groupDmNarrowWith(deactivatedUsers), + users: deactivatedUsers); + checkComposeBox(isShown: false); + + await changeUserStatus(tester, user: deactivatedUsers[0], isActive: true); + checkComposeBox(isShown: false); + + await changeUserStatus(tester, user: deactivatedUsers[1], isActive: true); + checkComposeBox(isShown: true); + }); + }); + }); }