diff --git a/assets/l10n/app_en.arb b/assets/l10n/app_en.arb index 26cd786315..4a16fef83a 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": "Label text for error banner when sending a message in a channel with no posting permission." + }, "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 4897b88629..661e526fd1 100644 --- a/lib/widgets/compose_box.dart +++ b/lib/widgets/compose_box.dart @@ -999,8 +999,21 @@ class _StreamComposeBoxState extends State<_StreamComposeBox> implements Compose super.dispose(); } + Widget? _errorBanner(BuildContext context) { + final store = PerAccountStoreWidget.of(context); + final selfUser = store.users[store.selfUserId]!; + final channel = store.streams[widget.narrow.streamId]!; + return channel.hasPostingPermission(selfUser, realmWaitingPeriodThreshold: store.realmWaitingPeriodThreshold) + ? null : _ErrorBanner(label: ZulipLocalizations.of(context).errorBannerCannotPostInChannelLabel); + } + @override Widget build(BuildContext context) { + final errorBanner = _errorBanner(context); + if (errorBanner != null) { + return _ComposeBoxContainer(child: errorBanner); + } + return _ComposeBoxLayout( contentController: _contentController, contentFocusNode: _contentFocusNode, @@ -1072,16 +1085,19 @@ class _FixedDestinationComposeBoxState extends State<_FixedDestinationComposeBox } 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); - } + final store = PerAccountStoreWidget.of(context); + switch (widget.narrow) { + case TopicNarrow(): + final selfUser = store.users[store.selfUserId]!; + final channel = store.streams[(widget.narrow as TopicNarrow).streamId]!; + return channel.hasPostingPermission(selfUser, 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; } - return null; } @override diff --git a/test/widgets/action_sheet_test.dart b/test/widgets/action_sheet_test.dart index f2a6403edf..9b9735ec32 100644 --- a/test/widgets/action_sheet_test.dart +++ b/test/widgets/action_sheet_test.dart @@ -43,7 +43,7 @@ Future setupToMessageActionSheet(WidgetTester tester, { await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot()); final store = await testBinding.globalStore.perAccount(eg.selfAccount.id); - await store.addUser(eg.user(userId: message.senderId)); + await store.addUsers([eg.selfUser, eg.user(userId: message.senderId)]); if (message is StreamMessage) { final stream = eg.stream(streamId: message.streamId); await store.addStream(stream); diff --git a/test/widgets/autocomplete_test.dart b/test/widgets/autocomplete_test.dart index 15c294696e..12396fcdc5 100644 --- a/test/widgets/autocomplete_test.dart +++ b/test/widgets/autocomplete_test.dart @@ -76,10 +76,12 @@ Future setupToTopicInput(WidgetTester tester, { addTearDown(testBinding.reset); await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot()); final store = await testBinding.globalStore.perAccount(eg.selfAccount.id); + await store.addUser(eg.selfUser); final connection = store.connection as FakeApiConnection; // prepare message list data final stream = eg.stream(); + await store.addStream(stream); final message = eg.streamMessage(stream: stream, sender: eg.selfUser); connection.prepare(json: GetMessagesResult( anchor: message.id, diff --git a/test/widgets/compose_box_test.dart b/test/widgets/compose_box_test.dart index db46367499..55b69a35f0 100644 --- a/test/widgets/compose_box_test.dart +++ b/test/widgets/compose_box_test.dart @@ -37,6 +37,11 @@ void main() { List users = const [], List streams = const [], }) async { + if (narrow is ChannelNarrow || narrow is TopicNarrow) { + final channelId = narrow is ChannelNarrow ? narrow.streamId : (narrow as TopicNarrow).streamId; + assert(streams.any((stream) => stream.streamId == channelId), + 'Add a channel with "streamId" the same as of $narrow.streamId to the store.'); + } addTearDown(testBinding.reset); final account = eg.account(user: selfUser ?? eg.selfUser); await testBinding.globalStore.add(account, eg.initialSnapshot( @@ -188,15 +193,17 @@ void main() { } testWidgets('_StreamComposeBox', (tester) async { + final channel = eg.stream(); final key = await prepareComposeBox(tester, - narrow: ChannelNarrow(eg.stream().streamId)); + narrow: ChannelNarrow(channel.streamId), streams: [channel]); checkComposeBoxTextFields(tester, controllerKey: key, expectTopicTextField: true); }); testWidgets('_FixedDestinationComposeBox', (tester) async { + final channel = eg.stream(); final key = await prepareComposeBox(tester, - narrow: TopicNarrow.ofMessage(eg.streamMessage())); + narrow: TopicNarrow(channel.streamId, 'topic'), streams: [channel]); checkComposeBoxTextFields(tester, controllerKey: key, expectTopicTextField: false); }); @@ -207,7 +214,8 @@ void main() { required void Function(int messageId) prepareResponse, }) async { final zulipLocalizations = GlobalLocalizations.zulipLocalizations; - await prepareComposeBox(tester, narrow: const TopicNarrow(123, 'some topic')); + await prepareComposeBox(tester, narrow: const TopicNarrow(123, 'some topic'), + streams: [eg.stream(streamId: 123)]); final contentInputFinder = find.byWidgetPredicate( (widget) => widget is TextField && widget.controller is ComposeContentController); @@ -271,7 +279,9 @@ void main() { group('attach from media library', () { testWidgets('success', (tester) async { - final controllerKey = await prepareComposeBox(tester, narrow: ChannelNarrow(eg.stream().streamId)); + final channel = eg.stream(); + final controllerKey = await prepareComposeBox(tester, + narrow: ChannelNarrow(channel.streamId), streams: [channel]); final composeBoxController = controllerKey.currentState!; // (When we check that the send button looks disabled, it should be because @@ -326,8 +336,10 @@ void main() { }); group('attach from camera', () { - testWidgets('success', (tester) async { - final controllerKey = await prepareComposeBox(tester, narrow: ChannelNarrow(eg.stream().streamId)); + testWidgets('succMessageListPageState.narrowess', (tester) async { + final channel = eg.stream(); + final controllerKey = await prepareComposeBox(tester, + narrow: ChannelNarrow(channel.streamId), streams: [channel]); final composeBoxController = controllerKey.currentState!; // (When we check that the send button looks disabled, it should be because @@ -382,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)); @@ -392,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, + daysToBecomeFullMember: 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, + daysToBecomeFullMember: 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('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 changeUserRole(tester, user: selfUser, role: UserRole.administrator); + checkComposeBox(isShown: true); }); - 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('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); + }); - await changeUserStatus(tester, user: deactivatedUsers[0], isActive: true); + 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); }); }); diff --git a/test/widgets/message_list_test.dart b/test/widgets/message_list_test.dart index bfe26444de..028c52744e 100644 --- a/test/widgets/message_list_test.dart +++ b/test/widgets/message_list_test.dart @@ -100,7 +100,8 @@ void main() { testWidgets('MessageListPageState.narrow', (tester) async { final stream = eg.stream(); await setupMessageListPage(tester, narrow: ChannelNarrow(stream.streamId), - messages: [eg.streamMessage(stream: stream, content: "

a message

")]); + messages: [eg.streamMessage(stream: stream, content: "

a message

")], + streams: [stream]); final state = MessageListPage.ancestorOf(tester.element(find.text("a message"))); check(state.narrow).equals(ChannelNarrow(stream.streamId)); }); @@ -108,7 +109,8 @@ void main() { testWidgets('composeBoxController finds compose box', (tester) async { final stream = eg.stream(); await setupMessageListPage(tester, narrow: ChannelNarrow(stream.streamId), - messages: [eg.streamMessage(stream: stream, content: "

a message

")]); + messages: [eg.streamMessage(stream: stream, content: "

a message

")], + streams: [stream]); final state = MessageListPage.ancestorOf(tester.element(find.text("a message"))); check(state.composeBoxController).isNotNull(); });