Skip to content

Commit 157418c

Browse files
sm-sayedignprice
authored andcommitted
compose: Replace compose box with a banner when cannot post in a channel
Fixes: #674
1 parent 388b83b commit 157418c

File tree

3 files changed

+159
-33
lines changed

3 files changed

+159
-33
lines changed

assets/l10n/app_en.arb

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -216,6 +216,10 @@
216216
"@errorBannerDeactivatedDmLabel": {
217217
"description": "Label text for error banner when sending a message to one or multiple deactivated users."
218218
},
219+
"errorBannerCannotPostInChannelLabel": "You do not have permission to post in this channel.",
220+
"@errorBannerCannotPostInChannelLabel": {
221+
"description": "Error-banner text replacing the compose box when you do not have permission to send a message to the channel."
222+
},
219223
"composeBoxAttachFilesTooltip": "Attach files",
220224
"@composeBoxAttachFilesTooltip": {
221225
"description": "Tooltip for compose box icon to attach a file to the message."

lib/widgets/compose_box.dart

Lines changed: 32 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1158,26 +1158,8 @@ class _FixedDestinationComposeBoxState extends State<_FixedDestinationComposeBox
11581158
super.dispose();
11591159
}
11601160

1161-
Widget? _errorBanner(BuildContext context) {
1162-
if (widget.narrow case DmNarrow(:final otherRecipientIds)) {
1163-
final store = PerAccountStoreWidget.of(context);
1164-
final hasDeactivatedUser = otherRecipientIds.any((id) =>
1165-
!(store.users[id]?.isActive ?? true));
1166-
if (hasDeactivatedUser) {
1167-
return _ErrorBanner(label: ZulipLocalizations.of(context)
1168-
.errorBannerDeactivatedDmLabel);
1169-
}
1170-
}
1171-
return null;
1172-
}
1173-
11741161
@override
11751162
Widget build(BuildContext context) {
1176-
final errorBanner = _errorBanner(context);
1177-
if (errorBanner != null) {
1178-
return _ComposeBoxContainer(child: errorBanner);
1179-
}
1180-
11811163
return _ComposeBoxLayout(
11821164
contentController: _contentController,
11831165
contentFocusNode: _contentFocusNode,
@@ -1215,8 +1197,40 @@ class ComposeBox extends StatelessWidget {
12151197
}
12161198
}
12171199

1200+
Widget? _errorBanner(BuildContext context) {
1201+
final store = PerAccountStoreWidget.of(context);
1202+
final selfUser = store.users[store.selfUserId]!;
1203+
switch (narrow) {
1204+
case ChannelNarrow(:final streamId):
1205+
case TopicNarrow(:final streamId):
1206+
final channel = store.streams[streamId];
1207+
if (channel == null || !store.hasPostingPermission(inChannel: channel,
1208+
user: selfUser, byDate: DateTime.now())) {
1209+
return _ErrorBanner(label:
1210+
ZulipLocalizations.of(context).errorBannerCannotPostInChannelLabel);
1211+
}
1212+
case DmNarrow(:final otherRecipientIds):
1213+
final hasDeactivatedUser = otherRecipientIds.any((id) =>
1214+
!(store.users[id]?.isActive ?? true));
1215+
if (hasDeactivatedUser) {
1216+
return _ErrorBanner(label:
1217+
ZulipLocalizations.of(context).errorBannerDeactivatedDmLabel);
1218+
}
1219+
case CombinedFeedNarrow():
1220+
case MentionsNarrow():
1221+
case StarredMessagesNarrow():
1222+
return null;
1223+
}
1224+
return null;
1225+
}
1226+
12181227
@override
12191228
Widget build(BuildContext context) {
1229+
final errorBanner = _errorBanner(context);
1230+
if (errorBanner != null) {
1231+
return _ComposeBoxContainer(child: errorBanner);
1232+
}
1233+
12201234
final narrow = this.narrow;
12211235
switch (narrow) {
12221236
case ChannelNarrow():

test/widgets/compose_box_test.dart

Lines changed: 123 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,8 @@ void main() {
4141

4242
Future<GlobalKey<ComposeBoxController>> prepareComposeBox(WidgetTester tester, {
4343
required Narrow narrow,
44+
User? selfUser,
45+
int? realmWaitingPeriodThreshold,
4446
List<User> users = const [],
4547
List<ZulipStream> streams = const [],
4648
}) async {
@@ -49,16 +51,19 @@ void main() {
4951
'Add a channel with "streamId" the same as of $narrow.streamId to the store.');
5052
}
5153
addTearDown(testBinding.reset);
52-
await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot());
54+
selfUser ??= eg.selfUser;
55+
final selfAccount = eg.account(user: selfUser);
56+
await testBinding.globalStore.add(selfAccount, eg.initialSnapshot(
57+
realmWaitingPeriodThreshold: realmWaitingPeriodThreshold));
5358

54-
store = await testBinding.globalStore.perAccount(eg.selfAccount.id);
59+
store = await testBinding.globalStore.perAccount(selfAccount.id);
5560

56-
await store.addUsers([eg.selfUser, ...users]);
61+
await store.addUsers([selfUser, ...users]);
5762
await store.addStreams(streams);
5863
connection = store.connection as FakeApiConnection;
5964

6065
final controllerKey = GlobalKey<ComposeBoxController>();
61-
await tester.pumpWidget(TestZulipApp(accountId: eg.selfAccount.id,
66+
await tester.pumpWidget(TestZulipApp(accountId: selfAccount.id,
6267
child: Column(
6368
// This positions the compose box at the bottom of the screen,
6469
// simulating the layout of the message list page.
@@ -303,17 +308,20 @@ void main() {
303308

304309
Future<void> prepareComposeBoxWithNavigation(WidgetTester tester) async {
305310
addTearDown(testBinding.reset);
306-
await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot());
311+
final selfUser = eg.selfUser;
312+
final selfAccount = eg.account(user: selfUser);
313+
await testBinding.globalStore.add(selfAccount, eg.initialSnapshot());
307314

308-
store = await testBinding.globalStore.perAccount(eg.selfAccount.id);
315+
store = await testBinding.globalStore.perAccount(selfAccount.id);
316+
await store.addUser(selfUser);
309317
await store.addStream(channel);
310318
connection = store.connection as FakeApiConnection;
311319

312320
await tester.pumpWidget(const ZulipApp());
313321
await tester.pump();
314322
final navigator = await ZulipApp.navigator;
315323
unawaited(navigator.push(MaterialAccountWidgetRoute(
316-
accountId: eg.selfAccount.id, page: ComposeBox(narrow: narrow))));
324+
accountId: selfAccount.id, page: ComposeBox(narrow: narrow))));
317325
await tester.pumpAndSettle();
318326
}
319327

@@ -581,7 +589,9 @@ void main() {
581589
});
582590

583591
group('error banner', () {
584-
Finder contentFieldFinder() => find.descendant(
592+
final zulipLocalizations = GlobalLocalizations.zulipLocalizations;
593+
594+
Finder inputFieldFinder() => find.descendant(
585595
of: find.byType(ComposeBox),
586596
matching: find.byType(TextField));
587597

@@ -590,24 +600,26 @@ void main() {
590600
matching: find.widgetWithIcon(IconButton, icon));
591601

592602
void checkComposeBoxParts({required bool areShown}) {
593-
check(contentFieldFinder().evaluate().length).equals(areShown ? 1 : 0);
603+
final inputFieldCount = inputFieldFinder().evaluate().length;
604+
areShown ? check(inputFieldCount).isGreaterThan(0) : check(inputFieldCount).equals(0);
594605
check(attachButtonFinder(Icons.attach_file).evaluate().length).equals(areShown ? 1 : 0);
595606
check(attachButtonFinder(Icons.image).evaluate().length).equals(areShown ? 1 : 0);
596607
check(attachButtonFinder(Icons.camera_alt).evaluate().length).equals(areShown ? 1 : 0);
597608
}
598609

599-
void checkBanner({required bool isShown}) {
600-
final bannerTextFinder = find.text(GlobalLocalizations.zulipLocalizations
601-
.errorBannerDeactivatedDmLabel);
602-
check(bannerTextFinder.evaluate().length).equals(isShown ? 1 : 0);
610+
void checkBannerWithLabel(String label, {required bool isShown}) {
611+
check(find.text(label).evaluate().length).equals(isShown ? 1 : 0);
603612
}
604613

605-
void checkComposeBox({required bool isShown}) {
614+
void checkComposeBoxIsShown(bool isShown, {required String bannerLabel}) {
606615
checkComposeBoxParts(areShown: isShown);
607-
checkBanner(isShown: !isShown);
616+
checkBannerWithLabel(bannerLabel, isShown: !isShown);
608617
}
609618

610619
group('in DMs with deactivated users', () {
620+
void checkComposeBox({required bool isShown}) => checkComposeBoxIsShown(isShown,
621+
bannerLabel: zulipLocalizations.errorBannerDeactivatedDmLabel);
622+
611623
Future<void> changeUserStatus(WidgetTester tester,
612624
{required User user, required bool isActive}) async {
613625
await store.handleEvent(RealmUserUpdateEvent(id: 1,
@@ -686,5 +698,101 @@ void main() {
686698
});
687699
});
688700
});
701+
702+
group('in channel/topic narrow according to channel post policy', () {
703+
void checkComposeBox({required bool isShown}) => checkComposeBoxIsShown(isShown,
704+
bannerLabel: zulipLocalizations.errorBannerCannotPostInChannelLabel);
705+
706+
final narrowTestCases = [
707+
('channel', const ChannelNarrow(1)),
708+
('topic', const TopicNarrow(1, 'topic')),
709+
];
710+
711+
for (final (String narrowType, Narrow narrow) in narrowTestCases) {
712+
testWidgets('compose box is shown in $narrowType narrow', (tester) async {
713+
await prepareComposeBox(tester,
714+
narrow: narrow,
715+
selfUser: eg.user(role: UserRole.administrator),
716+
streams: [eg.stream(streamId: 1,
717+
channelPostPolicy: ChannelPostPolicy.moderators)]);
718+
checkComposeBox(isShown: true);
719+
});
720+
721+
testWidgets('error banner is shown in $narrowType narrow', (tester) async {
722+
await prepareComposeBox(tester,
723+
narrow: narrow,
724+
selfUser: eg.user(role: UserRole.moderator),
725+
streams: [eg.stream(streamId: 1,
726+
channelPostPolicy: ChannelPostPolicy.administrators)]);
727+
checkComposeBox(isShown: false);
728+
});
729+
}
730+
731+
testWidgets('user loses privilege -> compose box is replaced with the banner', (tester) async {
732+
final selfUser = eg.user(role: UserRole.administrator);
733+
await prepareComposeBox(tester,
734+
narrow: const ChannelNarrow(1),
735+
selfUser: selfUser,
736+
streams: [eg.stream(streamId: 1,
737+
channelPostPolicy: ChannelPostPolicy.administrators)]);
738+
checkComposeBox(isShown: true);
739+
740+
await store.handleEvent(RealmUserUpdateEvent(id: 1,
741+
userId: selfUser.userId, role: UserRole.moderator));
742+
await tester.pump();
743+
checkComposeBox(isShown: false);
744+
});
745+
746+
testWidgets('user gains privilege -> banner is replaced with the compose box', (tester) async {
747+
final selfUser = eg.user(role: UserRole.guest);
748+
await prepareComposeBox(tester,
749+
narrow: const ChannelNarrow(1),
750+
selfUser: selfUser,
751+
streams: [eg.stream(streamId: 1,
752+
channelPostPolicy: ChannelPostPolicy.moderators)]);
753+
checkComposeBox(isShown: false);
754+
755+
await store.handleEvent(RealmUserUpdateEvent(id: 1,
756+
userId: selfUser.userId, role: UserRole.administrator));
757+
await tester.pump();
758+
checkComposeBox(isShown: true);
759+
});
760+
761+
testWidgets('channel policy becomes stricter -> compose box is replaced with the banner', (tester) async {
762+
final selfUser = eg.user(role: UserRole.guest);
763+
final channel = eg.stream(streamId: 1,
764+
channelPostPolicy: ChannelPostPolicy.any);
765+
766+
await prepareComposeBox(tester,
767+
narrow: const ChannelNarrow(1),
768+
selfUser: selfUser,
769+
streams: [channel]);
770+
checkComposeBox(isShown: true);
771+
772+
await store.handleEvent(eg.channelUpdateEvent(channel,
773+
property: ChannelPropertyName.channelPostPolicy,
774+
value: ChannelPostPolicy.fullMembers));
775+
await tester.pump();
776+
checkComposeBox(isShown: false);
777+
});
778+
779+
testWidgets('channel policy becomes less strict -> banner is replaced with the compose box', (tester) async {
780+
final selfUser = eg.user(role: UserRole.moderator);
781+
final channel = eg.stream(streamId: 1,
782+
channelPostPolicy: ChannelPostPolicy.administrators);
783+
784+
await prepareComposeBox(tester,
785+
narrow: const ChannelNarrow(1),
786+
selfUser: selfUser,
787+
streams: [channel]);
788+
checkComposeBox(isShown: false);
789+
790+
await store.handleEvent(eg.channelUpdateEvent(channel,
791+
property: ChannelPropertyName.channelPostPolicy,
792+
value: ChannelPostPolicy.moderators));
793+
await tester.pump();
794+
checkComposeBox(isShown: true);
795+
});
796+
});
689797
});
690798
}

0 commit comments

Comments
 (0)