Skip to content

compose_box: Replace compose box with a banner when cannot post in a channel #886

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

4 changes: 4 additions & 0 deletions assets/l10n/app_en.arb
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,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."
Expand Down
9 changes: 9 additions & 0 deletions lib/api/model/initial_snapshot.dart
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,14 @@ class InitialSnapshot {

final List<UserTopicItem>? userTopics; // TODO(server-6)

/// The number of days until a user's account is treated as a full member.
///
/// Search for "realm_waiting_period_threshold" in https://zulip.com/api/register-queue.
///
/// For how to determine if a user is a full member, see:
/// https://zulip.com/api/roles-and-permissions#determining-if-a-user-is-a-full-member
final int realmWaitingPeriodThreshold;

final Map<String, RealmDefaultExternalAccount> realmDefaultExternalAccounts;

final int maxFileUploadSizeMib;
Expand Down Expand Up @@ -117,6 +125,7 @@ class InitialSnapshot {
required this.streams,
required this.userSettings,
required this.userTopics,
required this.realmWaitingPeriodThreshold,
required this.realmDefaultExternalAccounts,
required this.maxFileUploadSizeMib,
required this.serverEmojiDataUrl,
Expand Down
3 changes: 3 additions & 0 deletions lib/api/model/initial_snapshot.g.dart

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions lib/api/model/model.dart
Original file line number Diff line number Diff line change
Expand Up @@ -303,6 +303,11 @@ enum UserRole{
final int? apiValue;

int? toJson() => apiValue;

bool isAtLeast(UserRole threshold) {
// Roles with more privilege have lower [apiValue].
return apiValue! <= threshold.apiValue!;
}
}

/// As in `streams` in the initial snapshot.
Expand Down
50 changes: 50 additions & 0 deletions lib/model/store.dart
Original file line number Diff line number Diff line change
Expand Up @@ -268,6 +268,7 @@ class PerAccountStore extends ChangeNotifier with EmojiStore, ChannelStore, Mess
globalStore: globalStore,
connection: connection,
realmUrl: realmUrl,
realmWaitingPeriodThreshold: initialSnapshot.realmWaitingPeriodThreshold,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks like this should come before maxFileUploadSizeMib.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Moved it before the line for maxFileUploadSizeMib.

Also, looking at these "realm and server" section properties, I'm unsure if they follow a specific order. If they're meant to follow the order of InitialSnapshot, then they'll have an order like this:

customProfileFields
realmWaitingPeriodThreshold
realmUrl
realmDefaultExternalAccounts
maxFileUploadSizeMib
emailAddressVisiblity

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The constructor arguments at this call site should match the order of the parameters in the constructor's declaration, which in turn should match the order the fields are declared in.

For the field declarations, generally we've tried to keep them in a logical order (which is our default approach for ordering things). So realmUrl comes before all the miscellaneous settings, because it's the value that identifies the whole realm in the first place. I don't think there's a strong reason the other five you listed are ordered in the way they currently are, though.

That logical ordering may differ from what's in InitialSnapshot, because:

class InitialSnapshot {
  // Keep these fields in the order they appear in the API docs.
  // (For many API types we choose a more logical order than the docs.
  // But this one is so long that that'd make it become impossible to
  // compare the lists by hand.)

maxFileUploadSizeMib: initialSnapshot.maxFileUploadSizeMib,
realmDefaultExternalAccounts: initialSnapshot.realmDefaultExternalAccounts,
customProfileFields: _sortCustomProfileFields(initialSnapshot.customProfileFields),
Expand Down Expand Up @@ -310,6 +311,7 @@ class PerAccountStore extends ChangeNotifier with EmojiStore, ChannelStore, Mess
required GlobalStore globalStore,
required this.connection,
required this.realmUrl,
required this.realmWaitingPeriodThreshold,
required this.maxFileUploadSizeMib,
required this.realmDefaultExternalAccounts,
required this.customProfileFields,
Expand Down Expand Up @@ -373,6 +375,8 @@ class PerAccountStore extends ChangeNotifier with EmojiStore, ChannelStore, Mess
Uri? tryResolveUrl(String reference) => _tryResolveUrl(realmUrl, reference);

String get zulipVersion => account.zulipVersion;
/// For docs, please see [InitialSnapshot.realmWaitingPeriodThreshold].
final int realmWaitingPeriodThreshold; // TODO(#668): update this realm setting
final int maxFileUploadSizeMib; // No event for this.
final Map<String, RealmDefaultExternalAccount> realmDefaultExternalAccounts;
List<CustomProfileField> customProfileFields;
Expand Down Expand Up @@ -430,6 +434,28 @@ class PerAccountStore extends ChangeNotifier with EmojiStore, ChannelStore, Mess

final TypingStatus typingStatus;

/// Whether [user] has passed the realm's waiting period to be a full member.
///
/// See:
/// https://zulip.com/api/roles-and-permissions#determining-if-a-user-is-a-full-member
///
/// To determine if a user is a full member, callers must also check that the
/// user's role is at least [UserRole.member].
bool hasPassedWaitingPeriod(User user, {required DateTime byDate}) {
// [User.dateJoined] is in UTC. For logged-in users, the format is:
// YYYY-MM-DDTHH:mm+00:00, which includes the timezone offset for UTC.
// For logged-out spectators, the format is: YYYY-MM-DD, which doesn't
// include the timezone offset. In the later case, [DateTime.parse] will
// interpret it as the client's local timezone, which could lead to
// incorrect results; but that's acceptable for now because the app
// doesn't support viewing as a spectator.
//
// See the related discussion:
// https://chat.zulip.org/#narrow/channel/412-api-documentation/topic/provide.20an.20explicit.20format.20for.20.60realm_user.2Edate_joined.60/near/1980194
final dateJoined = DateTime.parse(user.dateJoined);
return byDate.difference(dateJoined).inDays >= realmWaitingPeriodThreshold;
}

////////////////////////////////
// Streams, topics, and stuff about them.

Expand All @@ -448,6 +474,30 @@ class PerAccountStore extends ChangeNotifier with EmojiStore, ChannelStore, Mess

final ChannelStoreImpl _channels;

bool hasPostingPermission({
required ZulipStream inChannel,
required User user,
required DateTime byDate,
}) {
final role = user.role;
// We let the users with [unknown] role to send the message, then the server
// will decide to accept it or not based on its actual role.
if (role == UserRole.unknown) return true;

switch (inChannel.channelPostPolicy) {
case ChannelPostPolicy.any: return true;
case ChannelPostPolicy.fullMembers: {
if (!role.isAtLeast(UserRole.member)) return false;
return role == UserRole.member
? hasPassedWaitingPeriod(user, byDate: byDate)
: true;
}
case ChannelPostPolicy.moderators: return role.isAtLeast(UserRole.moderator);
case ChannelPostPolicy.administrators: return role.isAtLeast(UserRole.administrator);
case ChannelPostPolicy.unknown: return true;
}
}

////////////////////////////////
// Messages, and summaries of messages.

Expand Down
50 changes: 32 additions & 18 deletions lib/widgets/compose_box.dart
Original file line number Diff line number Diff line change
Expand Up @@ -1158,26 +1158,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,
Expand Down Expand Up @@ -1215,8 +1197,40 @@ class ComposeBox extends StatelessWidget {
}
}

Widget? _errorBanner(BuildContext context) {
final store = PerAccountStoreWidget.of(context);
final selfUser = store.users[store.selfUserId]!;
switch (narrow) {
case ChannelNarrow(:final streamId):
case TopicNarrow(:final streamId):
final channel = store.streams[streamId];
if (channel == null || !store.hasPostingPermission(inChannel: channel,
user: selfUser, byDate: DateTime.now())) {
return _ErrorBanner(label:
ZulipLocalizations.of(context).errorBannerCannotPostInChannelLabel);
}
case DmNarrow(:final otherRecipientIds):
final hasDeactivatedUser = otherRecipientIds.any((id) =>
!(store.users[id]?.isActive ?? true));
if (hasDeactivatedUser) {
return _ErrorBanner(label:
ZulipLocalizations.of(context).errorBannerDeactivatedDmLabel);
}
case CombinedFeedNarrow():
case MentionsNarrow():
case StarredMessagesNarrow():
return null;
}
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():
Expand Down
8 changes: 6 additions & 2 deletions test/example_data.dart
Original file line number Diff line number Diff line change
Expand Up @@ -110,8 +110,10 @@ User user({
String? deliveryEmail,
String? email,
String? fullName,
String? dateJoined,
bool? isActive,
bool? isBot,
UserRole? role,
String? avatarUrl,
Map<int, ProfileFieldUserData>? profileData,
}) {
Expand All @@ -122,13 +124,13 @@ User user({
deliveryEmail: effectiveDeliveryEmail,
email: email ?? effectiveDeliveryEmail,
fullName: fullName ?? 'A user', // TODO generate example names
dateJoined: '2023-04-28',
dateJoined: dateJoined ?? '2024-02-24T11:18+00:00',
isActive: isActive ?? true,
isBillingAdmin: false,
isBot: isBot ?? false,
botType: null,
botOwnerId: null,
role: UserRole.member,
role: role ?? UserRole.member,
timezone: 'UTC',
avatarUrl: avatarUrl,
avatarVersion: 0,
Expand Down Expand Up @@ -824,6 +826,7 @@ InitialSnapshot initialSnapshot({
List<ZulipStream>? streams,
UserSettings? userSettings,
List<UserTopicItem>? userTopics,
int? realmWaitingPeriodThreshold,
Map<String, RealmDefaultExternalAccount>? realmDefaultExternalAccounts,
int? maxFileUploadSizeMib,
Uri? serverEmojiDataUrl,
Expand Down Expand Up @@ -857,6 +860,7 @@ InitialSnapshot initialSnapshot({
emojiset: Emojiset.google,
),
userTopics: userTopics,
realmWaitingPeriodThreshold: realmWaitingPeriodThreshold ?? 0,
realmDefaultExternalAccounts: realmDefaultExternalAccounts ?? {},
maxFileUploadSizeMib: maxFileUploadSizeMib ?? 25,
serverEmojiDataUrl: serverEmojiDataUrl
Expand Down
98 changes: 98 additions & 0 deletions test/model/store_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -247,6 +247,104 @@ void main() {
});
});

group('PerAccountStore.hasPassedWaitingPeriod', () {
final store = eg.store(initialSnapshot:
eg.initialSnapshot(realmWaitingPeriodThreshold: 2));

final testCases = [
('2024-11-25T10:00+00:00', DateTime.utc(2024, 11, 25 + 0, 10, 00), false),
('2024-11-25T10:00+00:00', DateTime.utc(2024, 11, 25 + 1, 10, 00), false),
('2024-11-25T10:00+00:00', DateTime.utc(2024, 11, 25 + 2, 09, 59), false),
('2024-11-25T10:00+00:00', DateTime.utc(2024, 11, 25 + 2, 10, 00), true),
('2024-11-25T10:00+00:00', DateTime.utc(2024, 11, 25 + 1000, 07, 00), true),
];
Comment on lines +254 to +260
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Related to #886 comment, I wrote the tests only for the UTC format as DateTime only supports local or UTC time zone. Even if I wrote some tests for my local time zone, they'd have different (most probably incorrect) results for anyone else running the tests in their local time zone. I couldn't find a way to force the test environment to have a different time zone other than (my) local or UTC.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, since we're doing the computations all in terms of UTC (which is good news), that's the right way for the tests to work too.

I couldn't find a way to force the test environment to have a different time zone other than (my) local or UTC.

Unfortunately there doesn't seem to be such a way. We have a comment on one test where that came up, in test/widgets/message_list_test.dart (testing the dates we show in recipient headers):

      // We show the dates in the user's timezone.  Dart's standard library
      // doesn't give us a way to control which timezone is used — only to
      // choose between UTC and the user's timezone, whatever that may be.
      // So we do the latter, and that means these dates come out differently
      // depending on the timezone in the environment running the tests.
      // Related upstream issues:
      //   https://github.com/dart-lang/sdk/issues/28985 (about DateTime.now, not timezone)
      //   https://github.com/dart-lang/sdk/issues/44928 (about the Dart implementation's own internal tests)
      // For this test, just accept outputs corresponding to any possible timezone.
      tester.widget(find.textContaining(RegExp("Dec 1[89], 2022")));
      tester.widget(find.textContaining(RegExp("Aug 2[23], 2022")));


for (final (String dateJoined, DateTime currentDate, bool hasPassedWaitingPeriod) in testCases) {
test('user joined at $dateJoined ${hasPassedWaitingPeriod ? 'has' : "hasn't"} '
'passed waiting period by $currentDate', () {
final user = eg.user(dateJoined: dateJoined);
check(store.hasPassedWaitingPeriod(user, byDate: currentDate))
.equals(hasPassedWaitingPeriod);
});
}
});

group('PerAccountStore.hasPostingPermission', () {
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),
// The fullMembers/member case gets its own tests further below.
// (ChannelPostPolicy.fullMembers, UserRole.member, /* complicated */),
(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 (ChannelPostPolicy policy, UserRole role, bool canPost) in testCases) {
test('"${role.name}" user ${canPost ? 'can' : "can't"} post in channel '
'with "${policy.name}" policy', () {
final store = eg.store();
final actual = store.hasPostingPermission(
inChannel: eg.stream(channelPostPolicy: policy), user: eg.user(role: role),
// [byDate] is not actually relevant for these test cases; for the
// ones which it is, they're practiced below.
byDate: DateTime.now());
check(actual).equals(canPost);
});
}

group('"member" user posting in a channel with "fullMembers" policy', () {
PerAccountStore localStore({required int realmWaitingPeriodThreshold}) =>
eg.store(initialSnapshot: eg.initialSnapshot(
realmWaitingPeriodThreshold: realmWaitingPeriodThreshold));

User memberUser({required String dateJoined}) => eg.user(
role: UserRole.member, dateJoined: dateJoined);

test('a "full" member -> can post in the channel', () {
final store = localStore(realmWaitingPeriodThreshold: 3);
final hasPermission = store.hasPostingPermission(
inChannel: eg.stream(channelPostPolicy: ChannelPostPolicy.fullMembers),
user: memberUser(dateJoined: '2024-11-25T10:00+00:00'),
byDate: DateTime.utc(2024, 11, 28, 10, 00));
check(hasPermission).isTrue();
});

test('not a "full" member -> cannot post in the channel', () {
final store = localStore(realmWaitingPeriodThreshold: 3);
final actual = store.hasPostingPermission(
inChannel: eg.stream(channelPostPolicy: ChannelPostPolicy.fullMembers),
user: memberUser(dateJoined: '2024-11-25T10:00+00:00'),
byDate: DateTime.utc(2024, 11, 28, 09, 59));
check(actual).isFalse();
});
});
});

group('PerAccountStore.handleEvent', () {
// Mostly this method just dispatches to ChannelStore and MessageStore etc.,
// and so most of the tests live in the test files for those
Expand Down
2 changes: 1 addition & 1 deletion test/widgets/action_sheet_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ Future<void> setupToMessageActionSheet(WidgetTester tester, {

await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot());
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)]);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

compose_box: Replace compose box with a banner when cannot post in a channel

This seems like a reasonable change to the action-sheet tests, but why is it needed in this commit, which is about the compose box?

…Ah, I think I understand: it's a boring but necessary bit of setup so that the simulated action sheet doesn't crash in the code that decides whether to show the error banner. Is that right?

There are quite a few other changes in this commit that look like they're made for the same reason. Would you move those to a prep commit before this commit? That should make it easier to focus on the interesting changes here. 🙂

if (message is StreamMessage) {
final stream = eg.stream(streamId: message.streamId);
await store.addStream(stream);
Expand Down
2 changes: 2 additions & 0 deletions test/widgets/autocomplete_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -77,10 +77,12 @@ Future<Finder> 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);
Comment on lines 78 to +80
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What if we instead adjust eg.initialSnapshot so that it includes eg.selfUser in its user list by default? That seems like an improvement in realism.

I guess there might be a lot of existing tests that separately add that user, and they'd hit errors from adding a duplicate. In that case that change might still be a good idea; but it'd require a bigger effort, which let's leave out of scope for this PR.

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,
Expand Down
Loading