Skip to content

Commit 26a0a0f

Browse files
committed
compose_box: Replace compose box with a banner when cannot post in a channel
Fixes: zulip#674
1 parent 6973b7b commit 26a0a0f

File tree

3 files changed

+253
-71
lines changed

3 files changed

+253
-71
lines changed

assets/l10n/app_en.arb

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,10 @@
184184
"@errorBannerDeactivatedDmLabel": {
185185
"description": "Label text for error banner when sending a message to one or multiple deactivated users."
186186
},
187+
"errorBannerCannotPostInChannelLabel": "You do not have permission to post in this channel.",
188+
"@errorBannerCannotPostInChannelLabel": {
189+
"description": "Label text for error banner when sending a message in a channel with no posting permission."
190+
},
187191
"composeBoxAttachFilesTooltip": "Attach files",
188192
"@composeBoxAttachFilesTooltip": {
189193
"description": "Tooltip for compose box icon to attach a file to the message."

lib/widgets/compose_box.dart

Lines changed: 25 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -964,11 +964,23 @@ class _StreamComposeBoxState extends State<_StreamComposeBox> implements Compose
964964
super.dispose();
965965
}
966966

967+
Widget? _errorBanner(BuildContext context) {
968+
final store = PerAccountStoreWidget.of(context);
969+
final selfUser = store.users[store.selfUserId]!;
970+
final channel = store.streams[widget.narrow.streamId]!;
971+
return channel.hasPostingPermission(selfUser, realmWaitingPeriodThreshold: store.realmWaitingPeriodThreshold)
972+
? null : _ErrorBanner(label: ZulipLocalizations.of(context).errorBannerCannotPostInChannelLabel);
973+
}
974+
967975
@override
968976
Widget build(BuildContext context) {
977+
final errorBanner = _errorBanner(context);
978+
if (errorBanner != null) {
979+
return _ComposeBoxContainer(child: errorBanner);
980+
}
981+
969982
final colorScheme = Theme.of(context).colorScheme;
970983
final zulipLocalizations = ZulipLocalizations.of(context);
971-
972984
return _ComposeBoxLayout(
973985
contentController: _contentController,
974986
contentFocusNode: _contentFocusNode,
@@ -1039,16 +1051,19 @@ class _FixedDestinationComposeBoxState extends State<_FixedDestinationComposeBox
10391051
}
10401052

10411053
Widget? _errorBanner(BuildContext context) {
1042-
if (widget.narrow case DmNarrow(:final otherRecipientIds)) {
1043-
final store = PerAccountStoreWidget.of(context);
1044-
final hasDeactivatedUser = otherRecipientIds.any((id) =>
1045-
!(store.users[id]?.isActive ?? true));
1046-
if (hasDeactivatedUser) {
1047-
return _ErrorBanner(label: ZulipLocalizations.of(context)
1048-
.errorBannerDeactivatedDmLabel);
1049-
}
1054+
final store = PerAccountStoreWidget.of(context);
1055+
switch (widget.narrow) {
1056+
case TopicNarrow():
1057+
final selfUser = store.users[store.selfUserId]!;
1058+
final channel = store.streams[(widget.narrow as TopicNarrow).streamId]!;
1059+
return channel.hasPostingPermission(selfUser, realmWaitingPeriodThreshold: store.realmWaitingPeriodThreshold)
1060+
? null : _ErrorBanner(label: ZulipLocalizations.of(context).errorBannerCannotPostInChannelLabel);
1061+
case DmNarrow(:final otherRecipientIds):
1062+
final hasDeactivatedUser = otherRecipientIds.any((id) =>
1063+
!(store.users[id]?.isActive ?? true));
1064+
return hasDeactivatedUser ? _ErrorBanner(label: ZulipLocalizations.of(context)
1065+
.errorBannerDeactivatedDmLabel) : null;
10501066
}
1051-
return null;
10521067
}
10531068

10541069
@override

test/widgets/compose_box_test.dart

Lines changed: 224 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -376,8 +376,10 @@ void main() {
376376
});
377377
});
378378

379-
group('compose box in DMs with deactivated users', () {
380-
Finder contentFieldFinder() => find.descendant(
379+
group('compose box replacing with error banner', () {
380+
final zulipLocalizations = GlobalLocalizations.zulipLocalizations;
381+
382+
Finder inputFieldFinder() => find.descendant(
381383
of: find.byType(ComposeBox),
382384
matching: find.byType(TextField));
383385

@@ -386,97 +388,258 @@ void main() {
386388
matching: find.widgetWithIcon(IconButton, icon));
387389

388390
void checkComposeBoxParts({required bool areShown}) {
389-
check(contentFieldFinder().evaluate().length).equals(areShown ? 1 : 0);
391+
final inputFieldCount = inputFieldFinder().evaluate().length;
392+
areShown ? check(inputFieldCount).isGreaterThan(0) : check(inputFieldCount).equals(0);
390393
check(attachButtonFinder(Icons.attach_file).evaluate().length).equals(areShown ? 1 : 0);
391394
check(attachButtonFinder(Icons.image).evaluate().length).equals(areShown ? 1 : 0);
392395
check(attachButtonFinder(Icons.camera_alt).evaluate().length).equals(areShown ? 1 : 0);
393396
}
394397

395-
void checkBanner({required bool isShown}) {
396-
final bannerTextFinder = find.text(GlobalLocalizations.zulipLocalizations
397-
.errorBannerDeactivatedDmLabel);
398-
check(bannerTextFinder.evaluate().length).equals(isShown ? 1 : 0);
398+
void checkBannerWithLabel(String label, {required bool isShown}) {
399+
check(find.text(label).evaluate().length).equals(isShown ? 1 : 0);
399400
}
400401

401-
void checkComposeBox({required bool isShown}) {
402+
void checkComposeBoxIsShown(bool isShown, {required String bannerLabel}) {
402403
checkComposeBoxParts(areShown: isShown);
403-
checkBanner(isShown: !isShown);
404+
checkBannerWithLabel(bannerLabel, isShown: !isShown);
404405
}
405406

406-
Future<void> changeUserStatus(WidgetTester tester,
407-
{required User user, required bool isActive}) async {
408-
await store.handleEvent(RealmUserUpdateEvent(id: 1,
409-
userId: user.userId, isActive: isActive));
410-
await tester.pump();
411-
}
407+
group('in DMs with deactivated users', () {
408+
void checkComposeBox({required bool isShown}) => checkComposeBoxIsShown(isShown,
409+
bannerLabel: zulipLocalizations.errorBannerDeactivatedDmLabel);
412410

413-
DmNarrow dmNarrowWith(User otherUser) => DmNarrow.withUser(otherUser.userId,
414-
selfUserId: eg.selfUser.userId);
411+
Future<void> changeUserStatus(WidgetTester tester,
412+
{required User user, required bool isActive}) async {
413+
await store.handleEvent(RealmUserUpdateEvent(id: 1,
414+
userId: user.userId, isActive: isActive));
415+
await tester.pump();
416+
}
415417

416-
DmNarrow groupDmNarrowWith(List<User> otherUsers) => DmNarrow.withOtherUsers(
417-
otherUsers.map((u) => u.userId), selfUserId: eg.selfUser.userId);
418+
DmNarrow dmNarrowWith(User otherUser) => DmNarrow.withUser(otherUser.userId,
419+
selfUserId: eg.selfUser.userId);
418420

419-
group('1:1 DMs', () {
420-
testWidgets('compose box replaced with a banner', (tester) async {
421-
final deactivatedUser = eg.user(isActive: false);
422-
await prepareComposeBox(tester, narrow: dmNarrowWith(deactivatedUser),
423-
users: [deactivatedUser]);
424-
checkComposeBox(isShown: false);
425-
});
421+
DmNarrow groupDmNarrowWith(List<User> otherUsers) => DmNarrow.withOtherUsers(
422+
otherUsers.map((u) => u.userId), selfUserId: eg.selfUser.userId);
426423

427-
testWidgets('active user becomes deactivated -> '
428-
'compose box is replaced with a banner', (tester) async {
429-
final activeUser = eg.user(isActive: true);
430-
await prepareComposeBox(tester, narrow: dmNarrowWith(activeUser),
431-
users: [activeUser]);
432-
checkComposeBox(isShown: true);
424+
group('1:1 DMs', () {
425+
testWidgets('compose box replaced with a banner', (tester) async {
426+
final deactivatedUser = eg.user(isActive: false);
427+
await prepareComposeBox(tester, narrow: dmNarrowWith(deactivatedUser),
428+
users: [deactivatedUser]);
429+
checkComposeBox(isShown: false);
430+
});
433431

434-
await changeUserStatus(tester, user: activeUser, isActive: false);
435-
checkComposeBox(isShown: false);
432+
testWidgets('active user becomes deactivated -> '
433+
'compose box is replaced with a banner', (tester) async {
434+
final activeUser = eg.user(isActive: true);
435+
await prepareComposeBox(tester, narrow: dmNarrowWith(activeUser),
436+
users: [activeUser]);
437+
checkComposeBox(isShown: true);
438+
439+
await changeUserStatus(tester, user: activeUser, isActive: false);
440+
checkComposeBox(isShown: false);
441+
});
442+
443+
testWidgets('deactivated user becomes active -> '
444+
'banner is replaced with the compose box', (tester) async {
445+
final deactivatedUser = eg.user(isActive: false);
446+
await prepareComposeBox(tester, narrow: dmNarrowWith(deactivatedUser),
447+
users: [deactivatedUser]);
448+
checkComposeBox(isShown: false);
449+
450+
await changeUserStatus(tester, user: deactivatedUser, isActive: true);
451+
checkComposeBox(isShown: true);
452+
});
436453
});
437454

438-
testWidgets('deactivated user becomes active -> '
439-
'banner is replaced with the compose box', (tester) async {
440-
final deactivatedUser = eg.user(isActive: false);
441-
await prepareComposeBox(tester, narrow: dmNarrowWith(deactivatedUser),
442-
users: [deactivatedUser]);
443-
checkComposeBox(isShown: false);
455+
group('group DMs', () {
456+
testWidgets('compose box replaced with a banner', (tester) async {
457+
final deactivatedUsers = [eg.user(isActive: false), eg.user(isActive: false)];
458+
await prepareComposeBox(tester, narrow: groupDmNarrowWith(deactivatedUsers),
459+
users: deactivatedUsers);
460+
checkComposeBox(isShown: false);
461+
});
444462

445-
await changeUserStatus(tester, user: deactivatedUser, isActive: true);
446-
checkComposeBox(isShown: true);
463+
testWidgets('at least one user becomes deactivated -> '
464+
'compose box is replaced with a banner', (tester) async {
465+
final activeUsers = [eg.user(isActive: true), eg.user(isActive: true)];
466+
await prepareComposeBox(tester, narrow: groupDmNarrowWith(activeUsers),
467+
users: activeUsers);
468+
checkComposeBox(isShown: true);
469+
470+
await changeUserStatus(tester, user: activeUsers[0], isActive: false);
471+
checkComposeBox(isShown: false);
472+
});
473+
474+
testWidgets('all deactivated users become active -> '
475+
'banner is replaced with the compose box', (tester) async {
476+
final deactivatedUsers = [eg.user(isActive: false), eg.user(isActive: false)];
477+
await prepareComposeBox(tester, narrow: groupDmNarrowWith(deactivatedUsers),
478+
users: deactivatedUsers);
479+
checkComposeBox(isShown: false);
480+
481+
await changeUserStatus(tester, user: deactivatedUsers[0], isActive: true);
482+
checkComposeBox(isShown: false);
483+
484+
await changeUserStatus(tester, user: deactivatedUsers[1], isActive: true);
485+
checkComposeBox(isShown: true);
486+
});
447487
});
448488
});
449489

450-
group('group DMs', () {
451-
testWidgets('compose box replaced with a banner', (tester) async {
452-
final deactivatedUsers = [eg.user(isActive: false), eg.user(isActive: false)];
453-
await prepareComposeBox(tester, narrow: groupDmNarrowWith(deactivatedUsers),
454-
users: deactivatedUsers);
455-
checkComposeBox(isShown: false);
490+
group('in topic/channel narrow according to channel post policy', () {
491+
void checkComposeBox({required bool isShown}) => checkComposeBoxIsShown(isShown,
492+
bannerLabel: zulipLocalizations.errorBannerCannotPostInChannelLabel);
493+
494+
final testCases = [
495+
(ChannelPostPolicy.unknown, UserRole.unknown, true),
496+
(ChannelPostPolicy.unknown, UserRole.guest, true),
497+
(ChannelPostPolicy.unknown, UserRole.member, true),
498+
(ChannelPostPolicy.unknown, UserRole.moderator, true),
499+
(ChannelPostPolicy.unknown, UserRole.administrator, true),
500+
(ChannelPostPolicy.unknown, UserRole.owner, true),
501+
(ChannelPostPolicy.any, UserRole.unknown, true),
502+
(ChannelPostPolicy.any, UserRole.guest, true),
503+
(ChannelPostPolicy.any, UserRole.member, true),
504+
(ChannelPostPolicy.any, UserRole.moderator, true),
505+
(ChannelPostPolicy.any, UserRole.administrator, true),
506+
(ChannelPostPolicy.any, UserRole.owner, true),
507+
(ChannelPostPolicy.fullMembers, UserRole.unknown, true),
508+
(ChannelPostPolicy.fullMembers, UserRole.guest, false),
509+
(ChannelPostPolicy.fullMembers, UserRole.member, true),
510+
(ChannelPostPolicy.fullMembers, UserRole.moderator, true),
511+
(ChannelPostPolicy.fullMembers, UserRole.administrator, true),
512+
(ChannelPostPolicy.fullMembers, UserRole.owner, true),
513+
(ChannelPostPolicy.moderators, UserRole.unknown, true),
514+
(ChannelPostPolicy.moderators, UserRole.guest, false),
515+
(ChannelPostPolicy.moderators, UserRole.member, false),
516+
(ChannelPostPolicy.moderators, UserRole.moderator, true),
517+
(ChannelPostPolicy.moderators, UserRole.administrator, true),
518+
(ChannelPostPolicy.moderators, UserRole.owner, true),
519+
(ChannelPostPolicy.administrators, UserRole.unknown, true),
520+
(ChannelPostPolicy.administrators, UserRole.guest, false),
521+
(ChannelPostPolicy.administrators, UserRole.member, false),
522+
(ChannelPostPolicy.administrators, UserRole.moderator, false),
523+
(ChannelPostPolicy.administrators, UserRole.administrator, true),
524+
(ChannelPostPolicy.administrators, UserRole.owner, true),
525+
];
526+
527+
for (final testCase in testCases) {
528+
final (ChannelPostPolicy policy, UserRole role, bool canPost) = testCase;
529+
530+
testWidgets('"${role.name}" user ${canPost ? 'can' : "can't"} post in channel with "${policy.name}" policy', (tester) async {
531+
final selfUser = eg.user(role: role);
532+
await prepareComposeBox(tester,
533+
narrow: const ChannelNarrow(1),
534+
selfUser: selfUser,
535+
streams: [eg.stream(streamId: 1, channelPostPolicy: policy)],
536+
);
537+
checkComposeBox(isShown: canPost);
538+
});
539+
540+
testWidgets('"${role.name}" user ${canPost ? 'can' : "can't"} post in topic with "${policy.name}" channel policy', (tester) async {
541+
final selfUser = eg.user(role: role);
542+
await prepareComposeBox(tester,
543+
narrow: const TopicNarrow(1, 'topic'),
544+
selfUser: selfUser,
545+
streams: [eg.stream(streamId: 1, channelPostPolicy: policy)],
546+
);
547+
checkComposeBox(isShown: canPost);
548+
});
549+
550+
}
551+
552+
group('only "full member" user can post in channel with "fullMembers" policy', (){
553+
testWidgets('"full member" -> can post in channel', (tester) async {
554+
final selfUser = eg.user(role: UserRole.member,
555+
dateJoined: DateTime.now().subtract(const Duration(days: 30)).toIso8601String());
556+
await prepareComposeBox(tester,
557+
narrow: const ChannelNarrow(1),
558+
selfUser: selfUser,
559+
daysToBecomeFullMember: 30,
560+
streams: [eg.stream(streamId: 1, channelPostPolicy: ChannelPostPolicy.fullMembers)],
561+
);
562+
checkComposeBox(isShown: true);
563+
});
564+
565+
testWidgets('not a "full member" -> cannot post in channel', (tester) async {
566+
final selfUser = eg.user(role: UserRole.member,
567+
dateJoined: DateTime.now().subtract(const Duration(days: 29)).toIso8601String());
568+
await prepareComposeBox(tester,
569+
narrow: const ChannelNarrow(1),
570+
selfUser: selfUser,
571+
daysToBecomeFullMember: 30,
572+
streams: [eg.stream(streamId: 1, channelPostPolicy: ChannelPostPolicy.fullMembers)],
573+
);
574+
checkComposeBox(isShown: false);
575+
});
456576
});
457577

458-
testWidgets('at least one user becomes deactivated -> '
459-
'compose box is replaced with a banner', (tester) async {
460-
final activeUsers = [eg.user(isActive: true), eg.user(isActive: true)];
461-
await prepareComposeBox(tester, narrow: groupDmNarrowWith(activeUsers),
462-
users: activeUsers);
578+
Future<void> changeUserRole(WidgetTester tester,
579+
{required User user, required UserRole role}) async {
580+
await store.handleEvent(RealmUserUpdateEvent(id: 1,
581+
userId: user.userId, role: role));
582+
await tester.pump();
583+
}
584+
585+
Future<void> changeChannelPolicy(WidgetTester tester,
586+
{required ZulipStream channel, required ChannelPostPolicy policy}) async {
587+
await store.handleEvent(eg.channelUpdateEvent(channel,
588+
property: ChannelPropertyName.channelPostPolicy, value: policy));
589+
await tester.pump();
590+
}
591+
592+
testWidgets('user role decreases -> compose box is replaced with the banner', (tester) async {
593+
final selfUser = eg.user(role: UserRole.administrator);
594+
await prepareComposeBox(tester,
595+
narrow: const ChannelNarrow(1),
596+
selfUser: selfUser,
597+
streams: [eg.stream(streamId: 1, channelPostPolicy: ChannelPostPolicy.administrators)],
598+
);
463599
checkComposeBox(isShown: true);
464600

465-
await changeUserStatus(tester, user: activeUsers[0], isActive: false);
601+
await changeUserRole(tester, user: selfUser, role: UserRole.moderator);
466602
checkComposeBox(isShown: false);
467603
});
468604

469-
testWidgets('all deactivated users become active -> '
470-
'banner is replaced with the compose box', (tester) async {
471-
final deactivatedUsers = [eg.user(isActive: false), eg.user(isActive: false)];
472-
await prepareComposeBox(tester, narrow: groupDmNarrowWith(deactivatedUsers),
473-
users: deactivatedUsers);
605+
testWidgets('user role increases -> banner is replaced with the compose box', (tester) async {
606+
final selfUser = eg.user(role: UserRole.guest);
607+
await prepareComposeBox(tester,
608+
narrow: const ChannelNarrow(1),
609+
selfUser: selfUser,
610+
streams: [eg.stream(streamId: 1, channelPostPolicy: ChannelPostPolicy.moderators)],
611+
);
474612
checkComposeBox(isShown: false);
475613

476-
await changeUserStatus(tester, user: deactivatedUsers[0], isActive: true);
614+
await changeUserRole(tester, user: selfUser, role: UserRole.administrator);
615+
checkComposeBox(isShown: true);
616+
});
617+
618+
testWidgets('channel policy becomes stricter -> compose box is replaced with the banner', (tester) async {
619+
final selfUser = eg.user(role: UserRole.guest);
620+
final channel = eg.stream(streamId: 1, channelPostPolicy: ChannelPostPolicy.any);
621+
await prepareComposeBox(tester,
622+
narrow: const ChannelNarrow(1),
623+
selfUser: selfUser,
624+
streams: [channel],
625+
);
626+
checkComposeBox(isShown: true);
627+
628+
await changeChannelPolicy(tester, channel: channel, policy: ChannelPostPolicy.fullMembers);
629+
checkComposeBox(isShown: false);
630+
});
631+
632+
testWidgets('channel policy becomes less strict -> banner is replaced with the compose box', (tester) async {
633+
final selfUser = eg.user(role: UserRole.moderator);
634+
final channel = eg.stream(streamId: 1, channelPostPolicy: ChannelPostPolicy.administrators);
635+
await prepareComposeBox(tester,
636+
narrow: const ChannelNarrow(1),
637+
selfUser: selfUser,
638+
streams: [channel],
639+
);
477640
checkComposeBox(isShown: false);
478641

479-
await changeUserStatus(tester, user: deactivatedUsers[1], isActive: true);
642+
await changeChannelPolicy(tester, channel: channel, policy: ChannelPostPolicy.moderators);
480643
checkComposeBox(isShown: true);
481644
});
482645
});

0 commit comments

Comments
 (0)