Skip to content

Commit be0d74d

Browse files
committed
compose_box: Replace compose box with a banner when cannot post in a channel
Fixes: #674
1 parent 58276c3 commit be0d74d

File tree

3 files changed

+248
-79
lines changed

3 files changed

+248
-79
lines changed

assets/l10n/app_en.arb

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,10 @@
188188
"@errorBannerDeactivatedDmLabel": {
189189
"description": "Label text for error banner when sending a message to one or multiple deactivated users."
190190
},
191+
"errorBannerCannotPostInChannelLabel": "You do not have permission to post in this channel.",
192+
"@errorBannerCannotPostInChannelLabel": {
193+
"description": "Error-banner text replacing the compose box when you do not have permission to send a message to the channel."
194+
},
191195
"composeBoxAttachFilesTooltip": "Attach files",
192196
"@composeBoxAttachFilesTooltip": {
193197
"description": "Tooltip for compose box icon to attach a file to the message."

lib/widgets/compose_box.dart

Lines changed: 29 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1071,26 +1071,8 @@ class _FixedDestinationComposeBoxState extends State<_FixedDestinationComposeBox
10711071
super.dispose();
10721072
}
10731073

1074-
Widget? _errorBanner(BuildContext context) {
1075-
if (widget.narrow case DmNarrow(:final otherRecipientIds)) {
1076-
final store = PerAccountStoreWidget.of(context);
1077-
final hasDeactivatedUser = otherRecipientIds.any((id) =>
1078-
!(store.users[id]?.isActive ?? true));
1079-
if (hasDeactivatedUser) {
1080-
return _ErrorBanner(label: ZulipLocalizations.of(context)
1081-
.errorBannerDeactivatedDmLabel);
1082-
}
1083-
}
1084-
return null;
1085-
}
1086-
10871074
@override
10881075
Widget build(BuildContext context) {
1089-
final errorBanner = _errorBanner(context);
1090-
if (errorBanner != null) {
1091-
return _ComposeBoxContainer(child: errorBanner);
1092-
}
1093-
10941076
return _ComposeBoxLayout(
10951077
contentController: _contentController,
10961078
contentFocusNode: _contentFocusNode,
@@ -1128,8 +1110,37 @@ class ComposeBox extends StatelessWidget {
11281110
}
11291111
}
11301112

1113+
Widget? _errorBanner(BuildContext context) {
1114+
final store = PerAccountStoreWidget.of(context);
1115+
final selfUser = store.users[store.selfUserId]!;
1116+
switch (narrow) {
1117+
case ChannelNarrow narrow:
1118+
final channel = store.streams[narrow.streamId]!;
1119+
return channel.hasPostingPermission(selfUser, byDate: DateTime.now(), realmWaitingPeriodThreshold: store.realmWaitingPeriodThreshold)
1120+
? null : _ErrorBanner(label: ZulipLocalizations.of(context).errorBannerCannotPostInChannelLabel);
1121+
case TopicNarrow narrow:
1122+
final channel = store.streams[narrow.streamId]!;
1123+
return channel.hasPostingPermission(selfUser, byDate: DateTime.now(), realmWaitingPeriodThreshold: store.realmWaitingPeriodThreshold)
1124+
? null : _ErrorBanner(label: ZulipLocalizations.of(context).errorBannerCannotPostInChannelLabel);
1125+
case DmNarrow(:final otherRecipientIds):
1126+
final hasDeactivatedUser = otherRecipientIds.any((id) =>
1127+
!(store.users[id]?.isActive ?? true));
1128+
return hasDeactivatedUser ? _ErrorBanner(label: ZulipLocalizations.of(context)
1129+
.errorBannerDeactivatedDmLabel) : null;
1130+
case CombinedFeedNarrow():
1131+
case MentionsNarrow():
1132+
case StarredMessagesNarrow():
1133+
return null;
1134+
}
1135+
}
1136+
11311137
@override
11321138
Widget build(BuildContext context) {
1139+
final errorBanner = _errorBanner(context);
1140+
if (errorBanner != null) {
1141+
return _ComposeBoxContainer(child: errorBanner);
1142+
}
1143+
11331144
final narrow = this.narrow;
11341145
switch (narrow) {
11351146
case ChannelNarrow():

test/widgets/compose_box_test.dart

Lines changed: 215 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -395,8 +395,10 @@ void main() {
395395
});
396396
});
397397

398-
group('compose box in DMs with deactivated users', () {
399-
Finder contentFieldFinder() => find.descendant(
398+
group('compose box replacing with error banner', () {
399+
final zulipLocalizations = GlobalLocalizations.zulipLocalizations;
400+
401+
Finder inputFieldFinder() => find.descendant(
400402
of: find.byType(ComposeBox),
401403
matching: find.byType(TextField));
402404

@@ -405,97 +407,249 @@ void main() {
405407
matching: find.widgetWithIcon(IconButton, icon));
406408

407409
void checkComposeBoxParts({required bool areShown}) {
408-
check(contentFieldFinder().evaluate().length).equals(areShown ? 1 : 0);
410+
final inputFieldCount = inputFieldFinder().evaluate().length;
411+
areShown ? check(inputFieldCount).isGreaterThan(0) : check(inputFieldCount).equals(0);
409412
check(attachButtonFinder(Icons.attach_file).evaluate().length).equals(areShown ? 1 : 0);
410413
check(attachButtonFinder(Icons.image).evaluate().length).equals(areShown ? 1 : 0);
411414
check(attachButtonFinder(Icons.camera_alt).evaluate().length).equals(areShown ? 1 : 0);
412415
}
413416

414-
void checkBanner({required bool isShown}) {
415-
final bannerTextFinder = find.text(GlobalLocalizations.zulipLocalizations
416-
.errorBannerDeactivatedDmLabel);
417-
check(bannerTextFinder.evaluate().length).equals(isShown ? 1 : 0);
417+
void checkBannerWithLabel(String label, {required bool isShown}) {
418+
check(find.text(label).evaluate().length).equals(isShown ? 1 : 0);
418419
}
419420

420-
void checkComposeBox({required bool isShown}) {
421+
void checkComposeBoxIsShown(bool isShown, {required String bannerLabel}) {
421422
checkComposeBoxParts(areShown: isShown);
422-
checkBanner(isShown: !isShown);
423+
checkBannerWithLabel(bannerLabel, isShown: !isShown);
423424
}
424425

425-
Future<void> changeUserStatus(WidgetTester tester,
426-
{required User user, required bool isActive}) async {
427-
await store.handleEvent(RealmUserUpdateEvent(id: 1,
428-
userId: user.userId, isActive: isActive));
429-
await tester.pump();
430-
}
426+
group('in DMs with deactivated users', () {
427+
void checkComposeBox({required bool isShown}) => checkComposeBoxIsShown(isShown,
428+
bannerLabel: zulipLocalizations.errorBannerDeactivatedDmLabel);
431429

432-
DmNarrow dmNarrowWith(User otherUser) => DmNarrow.withUser(otherUser.userId,
433-
selfUserId: eg.selfUser.userId);
430+
Future<void> changeUserStatus(WidgetTester tester,
431+
{required User user, required bool isActive}) async {
432+
await store.handleEvent(RealmUserUpdateEvent(id: 1,
433+
userId: user.userId, isActive: isActive));
434+
await tester.pump();
435+
}
434436

435-
DmNarrow groupDmNarrowWith(List<User> otherUsers) => DmNarrow.withOtherUsers(
436-
otherUsers.map((u) => u.userId), selfUserId: eg.selfUser.userId);
437+
DmNarrow dmNarrowWith(User otherUser) => DmNarrow.withUser(otherUser.userId,
438+
selfUserId: eg.selfUser.userId);
437439

438-
group('1:1 DMs', () {
439-
testWidgets('compose box replaced with a banner', (tester) async {
440-
final deactivatedUser = eg.user(isActive: false);
441-
await prepareComposeBox(tester, narrow: dmNarrowWith(deactivatedUser),
442-
users: [deactivatedUser]);
443-
checkComposeBox(isShown: false);
444-
});
440+
DmNarrow groupDmNarrowWith(List<User> otherUsers) => DmNarrow.withOtherUsers(
441+
otherUsers.map((u) => u.userId), selfUserId: eg.selfUser.userId);
445442

446-
testWidgets('active user becomes deactivated -> '
447-
'compose box is replaced with a banner', (tester) async {
448-
final activeUser = eg.user(isActive: true);
449-
await prepareComposeBox(tester, narrow: dmNarrowWith(activeUser),
450-
users: [activeUser]);
451-
checkComposeBox(isShown: true);
443+
group('1:1 DMs', () {
444+
testWidgets('compose box replaced with a banner', (tester) async {
445+
final deactivatedUser = eg.user(isActive: false);
446+
await prepareComposeBox(tester, narrow: dmNarrowWith(deactivatedUser),
447+
users: [deactivatedUser]);
448+
checkComposeBox(isShown: false);
449+
});
452450

453-
await changeUserStatus(tester, user: activeUser, isActive: false);
454-
checkComposeBox(isShown: false);
451+
testWidgets('active user becomes deactivated -> '
452+
'compose box is replaced with a banner', (tester) async {
453+
final activeUser = eg.user(isActive: true);
454+
await prepareComposeBox(tester, narrow: dmNarrowWith(activeUser),
455+
users: [activeUser]);
456+
checkComposeBox(isShown: true);
457+
458+
await changeUserStatus(tester, user: activeUser, isActive: false);
459+
checkComposeBox(isShown: false);
460+
});
461+
462+
testWidgets('deactivated user becomes active -> '
463+
'banner is replaced with the compose box', (tester) async {
464+
final deactivatedUser = eg.user(isActive: false);
465+
await prepareComposeBox(tester, narrow: dmNarrowWith(deactivatedUser),
466+
users: [deactivatedUser]);
467+
checkComposeBox(isShown: false);
468+
469+
await changeUserStatus(tester, user: deactivatedUser, isActive: true);
470+
checkComposeBox(isShown: true);
471+
});
455472
});
456473

457-
testWidgets('deactivated user becomes active -> '
458-
'banner is replaced with the compose box', (tester) async {
459-
final deactivatedUser = eg.user(isActive: false);
460-
await prepareComposeBox(tester, narrow: dmNarrowWith(deactivatedUser),
461-
users: [deactivatedUser]);
462-
checkComposeBox(isShown: false);
474+
group('group DMs', () {
475+
testWidgets('compose box replaced with a banner', (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+
});
463481

464-
await changeUserStatus(tester, user: deactivatedUser, isActive: true);
465-
checkComposeBox(isShown: true);
482+
testWidgets('at least one user becomes deactivated -> '
483+
'compose box is replaced with a banner', (tester) async {
484+
final activeUsers = [eg.user(isActive: true), eg.user(isActive: true)];
485+
await prepareComposeBox(tester, narrow: groupDmNarrowWith(activeUsers),
486+
users: activeUsers);
487+
checkComposeBox(isShown: true);
488+
489+
await changeUserStatus(tester, user: activeUsers[0], isActive: false);
490+
checkComposeBox(isShown: false);
491+
});
492+
493+
testWidgets('all deactivated users become active -> '
494+
'banner is replaced with the compose box', (tester) async {
495+
final deactivatedUsers = [eg.user(isActive: false), eg.user(isActive: false)];
496+
await prepareComposeBox(tester, narrow: groupDmNarrowWith(deactivatedUsers),
497+
users: deactivatedUsers);
498+
checkComposeBox(isShown: false);
499+
500+
await changeUserStatus(tester, user: deactivatedUsers[0], isActive: true);
501+
checkComposeBox(isShown: false);
502+
503+
await changeUserStatus(tester, user: deactivatedUsers[1], isActive: true);
504+
checkComposeBox(isShown: true);
505+
});
466506
});
467507
});
468508

469-
group('group DMs', () {
470-
testWidgets('compose box replaced with a banner', (tester) async {
471-
final deactivatedUsers = [eg.user(isActive: false), eg.user(isActive: false)];
472-
await prepareComposeBox(tester, narrow: groupDmNarrowWith(deactivatedUsers),
473-
users: deactivatedUsers);
474-
checkComposeBox(isShown: false);
509+
group('in topic/channel narrow according to channel post policy', () {
510+
void checkComposeBox({required bool isShown}) => checkComposeBoxIsShown(isShown,
511+
bannerLabel: zulipLocalizations.errorBannerCannotPostInChannelLabel);
512+
513+
final testCases = [
514+
(ChannelPostPolicy.unknown, UserRole.unknown, true),
515+
(ChannelPostPolicy.unknown, UserRole.guest, true),
516+
(ChannelPostPolicy.unknown, UserRole.member, true),
517+
(ChannelPostPolicy.unknown, UserRole.moderator, true),
518+
(ChannelPostPolicy.unknown, UserRole.administrator, true),
519+
(ChannelPostPolicy.unknown, UserRole.owner, true),
520+
(ChannelPostPolicy.any, UserRole.unknown, true),
521+
(ChannelPostPolicy.any, UserRole.guest, true),
522+
(ChannelPostPolicy.any, UserRole.member, true),
523+
(ChannelPostPolicy.any, UserRole.moderator, true),
524+
(ChannelPostPolicy.any, UserRole.administrator, true),
525+
(ChannelPostPolicy.any, UserRole.owner, true),
526+
(ChannelPostPolicy.fullMembers, UserRole.unknown, true),
527+
(ChannelPostPolicy.fullMembers, UserRole.guest, false),
528+
(ChannelPostPolicy.fullMembers, UserRole.member, true),
529+
(ChannelPostPolicy.fullMembers, UserRole.moderator, true),
530+
(ChannelPostPolicy.fullMembers, UserRole.administrator, true),
531+
(ChannelPostPolicy.fullMembers, UserRole.owner, true),
532+
(ChannelPostPolicy.moderators, UserRole.unknown, true),
533+
(ChannelPostPolicy.moderators, UserRole.guest, false),
534+
(ChannelPostPolicy.moderators, UserRole.member, false),
535+
(ChannelPostPolicy.moderators, UserRole.moderator, true),
536+
(ChannelPostPolicy.moderators, UserRole.administrator, true),
537+
(ChannelPostPolicy.moderators, UserRole.owner, true),
538+
(ChannelPostPolicy.administrators, UserRole.unknown, true),
539+
(ChannelPostPolicy.administrators, UserRole.guest, false),
540+
(ChannelPostPolicy.administrators, UserRole.member, false),
541+
(ChannelPostPolicy.administrators, UserRole.moderator, false),
542+
(ChannelPostPolicy.administrators, UserRole.administrator, true),
543+
(ChannelPostPolicy.administrators, UserRole.owner, true),
544+
];
545+
546+
for (final testCase in testCases) {
547+
final (ChannelPostPolicy policy, UserRole role, bool canPost) = testCase;
548+
549+
testWidgets('"${role.name}" user ${canPost ? 'can' : "can't"} post in channel with "${policy.name}" policy', (tester) async {
550+
final selfUser = eg.user(role: role);
551+
await prepareComposeBox(tester,
552+
narrow: const ChannelNarrow(1),
553+
selfUser: selfUser,
554+
streams: [eg.stream(streamId: 1, channelPostPolicy: policy)]);
555+
checkComposeBox(isShown: canPost);
556+
});
557+
558+
testWidgets('"${role.name}" user ${canPost ? 'can' : "can't"} post in topic with "${policy.name}" channel policy', (tester) async {
559+
final selfUser = eg.user(role: role);
560+
await prepareComposeBox(tester,
561+
narrow: const TopicNarrow(1, 'topic'),
562+
selfUser: selfUser,
563+
streams: [eg.stream(streamId: 1, channelPostPolicy: policy)]);
564+
checkComposeBox(isShown: canPost);
565+
});
566+
}
567+
568+
group('only "full member" user can post in channel with "fullMembers" policy', () {
569+
testWidgets('"full member" -> can post in channel', (tester) async {
570+
final selfUser = eg.user(role: UserRole.member,
571+
dateJoined: DateTime.now().subtract(const Duration(days: 30)).toIso8601String());
572+
await prepareComposeBox(tester,
573+
narrow: const ChannelNarrow(1),
574+
selfUser: selfUser,
575+
realmWaitingPeriodThreshold: 30,
576+
streams: [eg.stream(streamId: 1, channelPostPolicy: ChannelPostPolicy.fullMembers)]);
577+
checkComposeBox(isShown: true);
578+
});
579+
580+
testWidgets('not a "full member" -> cannot post in channel', (tester) async {
581+
final selfUser = eg.user(role: UserRole.member,
582+
dateJoined: DateTime.now().subtract(const Duration(days: 29)).toIso8601String());
583+
await prepareComposeBox(tester,
584+
narrow: const ChannelNarrow(1),
585+
selfUser: selfUser,
586+
realmWaitingPeriodThreshold: 30,
587+
streams: [eg.stream(streamId: 1, channelPostPolicy: ChannelPostPolicy.fullMembers)]);
588+
checkComposeBox(isShown: false);
589+
});
475590
});
476591

477-
testWidgets('at least one user becomes deactivated -> '
478-
'compose box is replaced with a banner', (tester) async {
479-
final activeUsers = [eg.user(isActive: true), eg.user(isActive: true)];
480-
await prepareComposeBox(tester, narrow: groupDmNarrowWith(activeUsers),
481-
users: activeUsers);
592+
Future<void> changeUserRole(WidgetTester tester,
593+
{required User user, required UserRole role}) async {
594+
await store.handleEvent(RealmUserUpdateEvent(id: 1,
595+
userId: user.userId, role: role));
596+
await tester.pump();
597+
}
598+
599+
Future<void> changeChannelPolicy(WidgetTester tester,
600+
{required ZulipStream channel, required ChannelPostPolicy policy}) async {
601+
await store.handleEvent(eg.channelUpdateEvent(channel,
602+
property: ChannelPropertyName.channelPostPolicy, value: policy));
603+
await tester.pump();
604+
}
605+
606+
testWidgets('user role decreases -> compose box is replaced with the banner', (tester) async {
607+
final selfUser = eg.user(role: UserRole.administrator);
608+
await prepareComposeBox(tester,
609+
narrow: const ChannelNarrow(1),
610+
selfUser: selfUser,
611+
streams: [eg.stream(streamId: 1, channelPostPolicy: ChannelPostPolicy.administrators)]);
482612
checkComposeBox(isShown: true);
483613

484-
await changeUserStatus(tester, user: activeUsers[0], isActive: false);
614+
await changeUserRole(tester, user: selfUser, role: UserRole.moderator);
485615
checkComposeBox(isShown: false);
486616
});
487617

488-
testWidgets('all deactivated users become active -> '
489-
'banner is replaced with the compose box', (tester) async {
490-
final deactivatedUsers = [eg.user(isActive: false), eg.user(isActive: false)];
491-
await prepareComposeBox(tester, narrow: groupDmNarrowWith(deactivatedUsers),
492-
users: deactivatedUsers);
618+
testWidgets('user role increases -> banner is replaced with the compose box', (tester) async {
619+
final selfUser = eg.user(role: UserRole.guest);
620+
await prepareComposeBox(tester,
621+
narrow: const ChannelNarrow(1),
622+
selfUser: selfUser,
623+
streams: [eg.stream(streamId: 1, channelPostPolicy: ChannelPostPolicy.moderators)]);
493624
checkComposeBox(isShown: false);
494625

495-
await changeUserStatus(tester, user: deactivatedUsers[0], isActive: true);
626+
await changeUserRole(tester, user: selfUser, role: UserRole.administrator);
627+
checkComposeBox(isShown: true);
628+
});
629+
630+
testWidgets('channel policy becomes stricter -> compose box is replaced with the banner', (tester) async {
631+
final selfUser = eg.user(role: UserRole.guest);
632+
final channel = eg.stream(streamId: 1, channelPostPolicy: ChannelPostPolicy.any);
633+
await prepareComposeBox(tester,
634+
narrow: const ChannelNarrow(1),
635+
selfUser: selfUser,
636+
streams: [channel]);
637+
checkComposeBox(isShown: true);
638+
639+
await changeChannelPolicy(tester, channel: channel, policy: ChannelPostPolicy.fullMembers);
640+
checkComposeBox(isShown: false);
641+
});
642+
643+
testWidgets('channel policy becomes less strict -> banner is replaced with the compose box', (tester) async {
644+
final selfUser = eg.user(role: UserRole.moderator);
645+
final channel = eg.stream(streamId: 1, channelPostPolicy: ChannelPostPolicy.administrators);
646+
await prepareComposeBox(tester,
647+
narrow: const ChannelNarrow(1),
648+
selfUser: selfUser,
649+
streams: [channel]);
496650
checkComposeBox(isShown: false);
497651

498-
await changeUserStatus(tester, user: deactivatedUsers[1], isActive: true);
652+
await changeChannelPolicy(tester, channel: channel, policy: ChannelPostPolicy.moderators);
499653
checkComposeBox(isShown: true);
500654
});
501655
});

0 commit comments

Comments
 (0)