-
Notifications
You must be signed in to change notification settings - Fork 160
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
compose_box: Replace compose box with a banner when cannot post in a channel #886
base: main
Are you sure you want to change the base?
Changes from all commits
ffb3238
9616005
6cf3dd9
d1c16a9
5aaad2d
dc9a330
58276c3
be0d74d
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -263,6 +263,18 @@ class User { | |
factory User.fromJson(Map<String, dynamic> json) => _$UserFromJson(json); | ||
|
||
Map<String, dynamic> toJson() => _$UserToJson(this); | ||
|
||
/// Whether the 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 Role.Member. | ||
bool hasPassedWaitingPeriod(DateTime byDate, int realmWaitingPeriodThreshold) { | ||
final dateJoined = DateTime.parse(this.dateJoined); | ||
return byDate.difference(dateJoined).inDays >= realmWaitingPeriodThreshold; | ||
} | ||
} | ||
|
||
/// As in [User.profileData]. | ||
|
@@ -303,6 +315,11 @@ enum UserRole{ | |
final int? apiValue; | ||
|
||
int? toJson() => apiValue; | ||
|
||
bool isAtLeast(UserRole threshold) { | ||
// Roles with more privilege have lower [apiValue]. | ||
return (apiValue ?? 0) <= (threshold.apiValue ?? 0); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Looking at this, I was thinking about why This consideration is specific to the posting permission feature we are implementing here. I think the best thing to do is to assert that both api values are non-null; then we handle the case when either of them is null in |
||
} | ||
} | ||
|
||
/// As in `streams` in the initial snapshot. | ||
|
@@ -370,6 +387,19 @@ class ZulipStream { | |
_$ZulipStreamFromJson(json); | ||
|
||
Map<String, dynamic> toJson() => _$ZulipStreamToJson(this); | ||
|
||
bool hasPostingPermission(User user, {required DateTime byDate, required int realmWaitingPeriodThreshold}) { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. nit: let's break this into multiple lines |
||
final role = user.role; | ||
return switch (channelPostPolicy) { | ||
ChannelPostPolicy.any => true, | ||
ChannelPostPolicy.fullMembers => role.isAtLeast(UserRole.member) && (role == UserRole.member | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. nit: by turning this into |
||
? user.hasPassedWaitingPeriod(byDate, realmWaitingPeriodThreshold) | ||
: true), | ||
ChannelPostPolicy.moderators => role.isAtLeast(UserRole.moderator), | ||
ChannelPostPolicy.administrators => role.isAtLeast(UserRole.administrator), | ||
ChannelPostPolicy.unknown => true, | ||
}; | ||
} | ||
} | ||
|
||
/// The name of a property of [ZulipStream] that gets updated | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -235,6 +235,7 @@ class PerAccountStore extends ChangeNotifier with EmojiStore, ChannelStore, Mess | |
connection: connection, | ||
realmUrl: realmUrl, | ||
maxFileUploadSizeMib: initialSnapshot.maxFileUploadSizeMib, | ||
realmWaitingPeriodThreshold: initialSnapshot.realmWaitingPeriodThreshold, | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Looks like this should come before |
||
realmDefaultExternalAccounts: initialSnapshot.realmDefaultExternalAccounts, | ||
customProfileFields: _sortCustomProfileFields(initialSnapshot.customProfileFields), | ||
emailAddressVisibility: initialSnapshot.emailAddressVisibility, | ||
|
@@ -270,6 +271,7 @@ class PerAccountStore extends ChangeNotifier with EmojiStore, ChannelStore, Mess | |
required this.connection, | ||
required this.realmUrl, | ||
required this.maxFileUploadSizeMib, | ||
required this.realmWaitingPeriodThreshold, | ||
required this.realmDefaultExternalAccounts, | ||
required this.customProfileFields, | ||
required this.emailAddressVisibility, | ||
|
@@ -324,6 +326,8 @@ class PerAccountStore extends ChangeNotifier with EmojiStore, ChannelStore, Mess | |
|
||
String get zulipVersion => account.zulipVersion; | ||
final int maxFileUploadSizeMib; // No event for this. | ||
/// For docs, please see [InitialSnapshot.realmWaitingPeriodThreshold]. | ||
final int realmWaitingPeriodThreshold; // TODO(#668): update this realm setting | ||
final Map<String, RealmDefaultExternalAccount> realmDefaultExternalAccounts; | ||
List<CustomProfileField> customProfileFields; | ||
/// For docs, please see [InitialSnapshot.emailAddressVisibility]. | ||
|
Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
@@ -1071,26 +1071,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, | ||||||||||||||||||||||||||||||||||
|
@@ -1128,8 +1110,37 @@ class ComposeBox extends StatelessWidget { | |||||||||||||||||||||||||||||||||
} | ||||||||||||||||||||||||||||||||||
} | ||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||
Widget? _errorBanner(BuildContext context) { | ||||||||||||||||||||||||||||||||||
final store = PerAccountStoreWidget.of(context); | ||||||||||||||||||||||||||||||||||
final selfUser = store.users[store.selfUserId]!; | ||||||||||||||||||||||||||||||||||
switch (narrow) { | ||||||||||||||||||||||||||||||||||
case ChannelNarrow narrow: | ||||||||||||||||||||||||||||||||||
final channel = store.streams[narrow.streamId]!; | ||||||||||||||||||||||||||||||||||
return channel.hasPostingPermission(selfUser, byDate: DateTime.now(), realmWaitingPeriodThreshold: store.realmWaitingPeriodThreshold) | ||||||||||||||||||||||||||||||||||
? null : _ErrorBanner(label: ZulipLocalizations.of(context).errorBannerCannotPostInChannelLabel); | ||||||||||||||||||||||||||||||||||
case TopicNarrow narrow: | ||||||||||||||||||||||||||||||||||
final channel = store.streams[narrow.streamId]!; | ||||||||||||||||||||||||||||||||||
return channel.hasPostingPermission(selfUser, byDate: DateTime.now(), realmWaitingPeriodThreshold: store.realmWaitingPeriodThreshold) | ||||||||||||||||||||||||||||||||||
? null : _ErrorBanner(label: ZulipLocalizations.of(context).errorBannerCannotPostInChannelLabel); | ||||||||||||||||||||||||||||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. nit: let's extract
Comment on lines
+1117
to
+1124
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Then we can simplify this to:
Suggested change
|
||||||||||||||||||||||||||||||||||
case DmNarrow(:final otherRecipientIds): | ||||||||||||||||||||||||||||||||||
final hasDeactivatedUser = otherRecipientIds.any((id) => | ||||||||||||||||||||||||||||||||||
!(store.users[id]?.isActive ?? true)); | ||||||||||||||||||||||||||||||||||
return hasDeactivatedUser ? _ErrorBanner(label: ZulipLocalizations.of(context) | ||||||||||||||||||||||||||||||||||
.errorBannerDeactivatedDmLabel) : null; | ||||||||||||||||||||||||||||||||||
Comment on lines
+1128
to
+1129
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. nit: to make the lines shorter:
Suggested change
|
||||||||||||||||||||||||||||||||||
case CombinedFeedNarrow(): | ||||||||||||||||||||||||||||||||||
case MentionsNarrow(): | ||||||||||||||||||||||||||||||||||
case StarredMessagesNarrow(): | ||||||||||||||||||||||||||||||||||
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(): | ||||||||||||||||||||||||||||||||||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -43,7 +43,7 @@ Future<void> 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)]); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
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); | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We could simplify this by parsing
dateJoined
as we deserialize the User JSON object.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We could, but I'd prefer to keep that parsing deferred: converting user objects from JSON is a hot spot performance-wise, because there can be so many of them, and parsing a DateTime is the sort of thing that I feel could easily be slow.
And conversely I think there's only a few places like here where we care about the value of
dateJoined
, so it's not much burden on code complexity for these to have to handle the parsing; and they're all for just one user at a time, so there's no performance concern.