diff --git a/assets/l10n/app_en.arb b/assets/l10n/app_en.arb index 7c8d4981c7..5a48a2b73c 100644 --- a/assets/l10n/app_en.arb +++ b/assets/l10n/app_en.arb @@ -188,6 +188,10 @@ "@errorBannerDeactivatedDmLabel": { "description": "Label text for error banner when sending a message to one or multiple deactivated users." }, + "errorBannerCannotPostInChannelLabel": "You do not have permission to post in this channel.", + "@errorBannerCannotPostInChannelLabel": { + "description": "Error-banner text replacing the compose box when you do not have permission to send a message to the channel." + }, "composeBoxAttachFilesTooltip": "Attach files", "@composeBoxAttachFilesTooltip": { "description": "Tooltip for compose box icon to attach a file to the message." diff --git a/lib/widgets/compose_box.dart b/lib/widgets/compose_box.dart index d20cf9fb4b..c25cc5f343 100644 --- a/lib/widgets/compose_box.dart +++ b/lib/widgets/compose_box.dart @@ -1071,26 +1071,8 @@ 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) { - final errorBanner = _errorBanner(context); - if (errorBanner != null) { - return _ComposeBoxContainer(child: errorBanner); - } - return _ComposeBoxLayout( contentController: _contentController, contentFocusNode: _contentFocusNode, @@ -1128,8 +1110,37 @@ class ComposeBox extends StatelessWidget { } } + Widget? _errorBanner(BuildContext context) { + final store = PerAccountStoreWidget.of(context); + final selfUser = store.users[store.selfUserId]!; + switch (narrow) { + case ChannelNarrow narrow: + final channel = store.streams[narrow.streamId]!; + return channel.hasPostingPermission(selfUser, byDate: DateTime.now(), realmWaitingPeriodThreshold: store.realmWaitingPeriodThreshold) + ? null : _ErrorBanner(label: ZulipLocalizations.of(context).errorBannerCannotPostInChannelLabel); + case TopicNarrow narrow: + final channel = store.streams[narrow.streamId]!; + return channel.hasPostingPermission(selfUser, byDate: DateTime.now(), realmWaitingPeriodThreshold: store.realmWaitingPeriodThreshold) + ? null : _ErrorBanner(label: ZulipLocalizations.of(context).errorBannerCannotPostInChannelLabel); + case DmNarrow(:final otherRecipientIds): + final hasDeactivatedUser = otherRecipientIds.any((id) => + !(store.users[id]?.isActive ?? true)); + return hasDeactivatedUser ? _ErrorBanner(label: ZulipLocalizations.of(context) + .errorBannerDeactivatedDmLabel) : null; + case CombinedFeedNarrow(): + case MentionsNarrow(): + case StarredMessagesNarrow(): + return null; + } + } + @override Widget build(BuildContext context) { + final errorBanner = _errorBanner(context); + if (errorBanner != null) { + return _ComposeBoxContainer(child: errorBanner); + } + final narrow = this.narrow; switch (narrow) { case ChannelNarrow(): diff --git a/test/widgets/compose_box_test.dart b/test/widgets/compose_box_test.dart index 63d62b21aa..6d5c59f9c4 100644 --- a/test/widgets/compose_box_test.dart +++ b/test/widgets/compose_box_test.dart @@ -394,8 +394,10 @@ void main() { }); }); - group('compose box in DMs with deactivated users', () { - Finder contentFieldFinder() => find.descendant( + group('compose box replacing with error banner', () { + final zulipLocalizations = GlobalLocalizations.zulipLocalizations; + + Finder inputFieldFinder() => find.descendant( of: find.byType(ComposeBox), matching: find.byType(TextField)); @@ -404,97 +406,258 @@ void main() { matching: find.widgetWithIcon(IconButton, icon)); void checkComposeBoxParts({required bool areShown}) { - check(contentFieldFinder().evaluate().length).equals(areShown ? 1 : 0); + final inputFieldCount = inputFieldFinder().evaluate().length; + areShown ? check(inputFieldCount).isGreaterThan(0) : check(inputFieldCount).equals(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 checkBannerWithLabel(String label, {required bool isShown}) { + check(find.text(label).evaluate().length).equals(isShown ? 1 : 0); } - void checkComposeBox({required bool isShown}) { + void checkComposeBoxIsShown(bool isShown, {required String bannerLabel}) { checkComposeBoxParts(areShown: isShown); - checkBanner(isShown: !isShown); + checkBannerWithLabel(bannerLabel, 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(); - } + group('in DMs with deactivated users', () { + void checkComposeBox({required bool isShown}) => checkComposeBoxIsShown(isShown, + bannerLabel: zulipLocalizations.errorBannerDeactivatedDmLabel); - DmNarrow dmNarrowWith(User otherUser) => DmNarrow.withUser(otherUser.userId, - selfUserId: eg.selfUser.userId); + 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 groupDmNarrowWith(List otherUsers) => DmNarrow.withOtherUsers( - otherUsers.map((u) => u.userId), selfUserId: eg.selfUser.userId); + DmNarrow dmNarrowWith(User otherUser) => DmNarrow.withUser(otherUser.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); - }); + DmNarrow groupDmNarrowWith(List otherUsers) => DmNarrow.withOtherUsers( + otherUsers.map((u) => u.userId), selfUserId: eg.selfUser.userId); - 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); + 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); + }); - await changeUserStatus(tester, user: activeUser, isActive: false); - 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); + }); }); - 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); + 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); + }); - await changeUserStatus(tester, user: deactivatedUser, isActive: true); - checkComposeBox(isShown: true); + 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); + }); }); }); - 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); + group('in topic/channel narrow according to channel post policy', () { + void checkComposeBox({required bool isShown}) => checkComposeBoxIsShown(isShown, + bannerLabel: zulipLocalizations.errorBannerCannotPostInChannelLabel); + + final testCases = [ + (ChannelPostPolicy.unknown, UserRole.unknown, true), + (ChannelPostPolicy.unknown, UserRole.guest, true), + (ChannelPostPolicy.unknown, UserRole.member, true), + (ChannelPostPolicy.unknown, UserRole.moderator, true), + (ChannelPostPolicy.unknown, UserRole.administrator, true), + (ChannelPostPolicy.unknown, UserRole.owner, true), + (ChannelPostPolicy.any, UserRole.unknown, true), + (ChannelPostPolicy.any, UserRole.guest, true), + (ChannelPostPolicy.any, UserRole.member, true), + (ChannelPostPolicy.any, UserRole.moderator, true), + (ChannelPostPolicy.any, UserRole.administrator, true), + (ChannelPostPolicy.any, UserRole.owner, true), + (ChannelPostPolicy.fullMembers, UserRole.unknown, true), + (ChannelPostPolicy.fullMembers, UserRole.guest, false), + (ChannelPostPolicy.fullMembers, UserRole.member, true), + (ChannelPostPolicy.fullMembers, UserRole.moderator, true), + (ChannelPostPolicy.fullMembers, UserRole.administrator, true), + (ChannelPostPolicy.fullMembers, UserRole.owner, true), + (ChannelPostPolicy.moderators, UserRole.unknown, true), + (ChannelPostPolicy.moderators, UserRole.guest, false), + (ChannelPostPolicy.moderators, UserRole.member, false), + (ChannelPostPolicy.moderators, UserRole.moderator, true), + (ChannelPostPolicy.moderators, UserRole.administrator, true), + (ChannelPostPolicy.moderators, UserRole.owner, true), + (ChannelPostPolicy.administrators, UserRole.unknown, true), + (ChannelPostPolicy.administrators, UserRole.guest, false), + (ChannelPostPolicy.administrators, UserRole.member, false), + (ChannelPostPolicy.administrators, UserRole.moderator, false), + (ChannelPostPolicy.administrators, UserRole.administrator, true), + (ChannelPostPolicy.administrators, UserRole.owner, true), + ]; + + for (final testCase in testCases) { + final (ChannelPostPolicy policy, UserRole role, bool canPost) = testCase; + + testWidgets('"${role.name}" user ${canPost ? 'can' : "can't"} post in channel with "${policy.name}" policy', (tester) async { + final selfUser = eg.user(role: role); + await prepareComposeBox(tester, + narrow: const ChannelNarrow(1), + selfUser: selfUser, + streams: [eg.stream(streamId: 1, channelPostPolicy: policy)], + ); + checkComposeBox(isShown: canPost); + }); + + testWidgets('"${role.name}" user ${canPost ? 'can' : "can't"} post in topic with "${policy.name}" channel policy', (tester) async { + final selfUser = eg.user(role: role); + await prepareComposeBox(tester, + narrow: const TopicNarrow(1, 'topic'), + selfUser: selfUser, + streams: [eg.stream(streamId: 1, channelPostPolicy: policy)], + ); + checkComposeBox(isShown: canPost); + }); + + } + + group('only "full member" user can post in channel with "fullMembers" policy', (){ + testWidgets('"full member" -> can post in channel', (tester) async { + final selfUser = eg.user(role: UserRole.member, + dateJoined: DateTime.now().subtract(const Duration(days: 30)).toIso8601String()); + await prepareComposeBox(tester, + narrow: const ChannelNarrow(1), + selfUser: selfUser, + realmWaitingPeriodThreshold: 30, + streams: [eg.stream(streamId: 1, channelPostPolicy: ChannelPostPolicy.fullMembers)], + ); + checkComposeBox(isShown: true); + }); + + testWidgets('not a "full member" -> cannot post in channel', (tester) async { + final selfUser = eg.user(role: UserRole.member, + dateJoined: DateTime.now().subtract(const Duration(days: 29)).toIso8601String()); + await prepareComposeBox(tester, + narrow: const ChannelNarrow(1), + selfUser: selfUser, + realmWaitingPeriodThreshold: 30, + streams: [eg.stream(streamId: 1, channelPostPolicy: ChannelPostPolicy.fullMembers)], + ); + 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); + Future changeUserRole(WidgetTester tester, + {required User user, required UserRole role}) async { + await store.handleEvent(RealmUserUpdateEvent(id: 1, + userId: user.userId, role: role)); + await tester.pump(); + } + + Future changeChannelPolicy(WidgetTester tester, + {required ZulipStream channel, required ChannelPostPolicy policy}) async { + await store.handleEvent(eg.channelUpdateEvent(channel, + property: ChannelPropertyName.channelPostPolicy, value: policy)); + await tester.pump(); + } + + testWidgets('user role decreases -> compose box is replaced with the banner', (tester) async { + final selfUser = eg.user(role: UserRole.administrator); + await prepareComposeBox(tester, + narrow: const ChannelNarrow(1), + selfUser: selfUser, + streams: [eg.stream(streamId: 1, channelPostPolicy: ChannelPostPolicy.administrators)], + ); checkComposeBox(isShown: true); - await changeUserStatus(tester, user: activeUsers[0], isActive: false); + await changeUserRole(tester, user: selfUser, role: UserRole.moderator); 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); + testWidgets('user role increases -> banner is replaced with the compose box', (tester) async { + final selfUser = eg.user(role: UserRole.guest); + await prepareComposeBox(tester, + narrow: const ChannelNarrow(1), + selfUser: selfUser, + streams: [eg.stream(streamId: 1, channelPostPolicy: ChannelPostPolicy.moderators)], + ); checkComposeBox(isShown: false); - await changeUserStatus(tester, user: deactivatedUsers[0], isActive: true); + await changeUserRole(tester, user: selfUser, role: UserRole.administrator); + checkComposeBox(isShown: true); + }); + + testWidgets('channel policy becomes stricter -> compose box is replaced with the banner', (tester) async { + final selfUser = eg.user(role: UserRole.guest); + final channel = eg.stream(streamId: 1, channelPostPolicy: ChannelPostPolicy.any); + await prepareComposeBox(tester, + narrow: const ChannelNarrow(1), + selfUser: selfUser, + streams: [channel], + ); + checkComposeBox(isShown: true); + + await changeChannelPolicy(tester, channel: channel, policy: ChannelPostPolicy.fullMembers); + checkComposeBox(isShown: false); + }); + + testWidgets('channel policy becomes less strict -> banner is replaced with the compose box', (tester) async { + final selfUser = eg.user(role: UserRole.moderator); + final channel = eg.stream(streamId: 1, channelPostPolicy: ChannelPostPolicy.administrators); + await prepareComposeBox(tester, + narrow: const ChannelNarrow(1), + selfUser: selfUser, + streams: [channel], + ); checkComposeBox(isShown: false); - await changeUserStatus(tester, user: deactivatedUsers[1], isActive: true); + await changeChannelPolicy(tester, channel: channel, policy: ChannelPostPolicy.moderators); checkComposeBox(isShown: true); }); });