Skip to content
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

api: Add stream/update event #880

Merged
merged 5 commits into from
Aug 21, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 69 additions & 3 deletions lib/api/model/events.dart
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ sealed class Event {
switch (json['op'] as String) {
case 'create': return ChannelCreateEvent.fromJson(json);
case 'delete': return ChannelDeleteEvent.fromJson(json);
// TODO(#182): case 'update':
case 'update': return ChannelUpdateEvent.fromJson(json);
default: return UnexpectedEvent.fromJson(json);
}
case 'subscription':
Expand Down Expand Up @@ -379,8 +379,74 @@ class ChannelDeleteEvent extends ChannelEvent {
Map<String, dynamic> toJson() => _$ChannelDeleteEventToJson(this);
}

// TODO(#182) ChannelUpdateEvent, for a [ChannelEvent] with op `update`:
// https://zulip.com/api/get-events#stream-update
/// A [ChannelEvent] with op `update`: https://zulip.com/api/get-events#stream-update
@JsonSerializable(fieldRename: FieldRename.snake)
class ChannelUpdateEvent extends ChannelEvent {
@override
String get op => 'update';

final int streamId;
final String name;

/// The name of the channel property, or null if we don't recognize it.
@JsonKey(unknownEnumValue: JsonKey.nullForUndefinedEnumValue)
final ChannelPropertyName? property;

/// The new value, or null if we don't recognize the property.
///
/// This will have the type appropriate for [property]; for example,
/// if the property is boolean, then `value is bool` will always be true.
/// This invariant is enforced by [ChannelUpdateEvent.fromJson].
@JsonKey(readValue: _readValue)
final Object? value;

final String? renderedDescription;
final bool? historyPublicToSubscribers;
final bool? isWebPublic;

ChannelUpdateEvent({
required super.id,
required this.streamId,
required this.name,
required this.property,
required this.value,
this.renderedDescription,
this.historyPublicToSubscribers,
this.isWebPublic,
});

/// [value], with a check that its type corresponds to [property]
/// (e.g., `value as bool`).
static Object? _readValue(Map<dynamic, dynamic> json, String key) {
final value = json['value'];
switch (ChannelPropertyName.fromRawString(json['property'] as String)) {
case ChannelPropertyName.name:
case ChannelPropertyName.description:
return value as String;
case ChannelPropertyName.firstMessageId:
return value as int?;
case ChannelPropertyName.inviteOnly:
return value as bool;
case ChannelPropertyName.messageRetentionDays:
return value as int?;
case ChannelPropertyName.channelPostPolicy:
return ChannelPostPolicy.fromApiValue(value as int);
case ChannelPropertyName.canRemoveSubscribersGroup:
case ChannelPropertyName.canRemoveSubscribersGroupId:
return value as int;
case ChannelPropertyName.streamWeeklyTraffic:
return value as int?;
case null:
return null;
}
}

factory ChannelUpdateEvent.fromJson(Map<String, dynamic> json) =>
_$ChannelUpdateEventFromJson(json);

@override
Map<String, dynamic> toJson() => _$ChannelUpdateEventToJson(this);
}

/// A Zulip event of type `subscription`.
///
Expand Down
41 changes: 41 additions & 0 deletions lib/api/model/events.g.dart

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

70 changes: 59 additions & 11 deletions lib/api/model/model.dart
Original file line number Diff line number Diff line change
Expand Up @@ -312,29 +312,37 @@ enum UserRole{
/// in <https://zulip.com/api/register-queue>.
@JsonSerializable(fieldRename: FieldRename.snake)
class ZulipStream {
// When adding a field to this class:
Copy link
Collaborator

Choose a reason for hiding this comment

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

commit message nit: Let's try to use more brief wording, say:

model [nfc]: Clarify how to add updatable fields to `ZulipStream`

or

model [nfc]: Clarify when to add non-final fields to `ZulipStream`

Also, I believe this is closely related to the previous commit
api: Add stream/update event
So, most probably this will get squashed into it during maintainer or integration review.

// * Add it to [ChannelPropertyName] too, or add a comment there explaining
// why there isn't a corresponding value in that enum.
// * If the field can never change for a given Zulip stream, mark it final.
// Otherwise, make sure it gets updated on [ChannelUpdateEvent].
// * (If it can change but [ChannelUpdateEvent] doesn't cover that,
// then that's a bug in the API; raise it in `#api design`.)

final int streamId;
final String name;
final String description;
final String renderedDescription;
String name;
String description;
String renderedDescription;

final int dateCreated;
final int? firstMessageId;
int? firstMessageId;

final bool inviteOnly;
final bool isWebPublic; // present since 2.1, according to /api/changelog
final bool historyPublicToSubscribers;
final int? messageRetentionDays;
bool inviteOnly;
bool isWebPublic; // present since 2.1, according to /api/changelog
bool historyPublicToSubscribers;
int? messageRetentionDays;
@JsonKey(name: 'stream_post_policy')
final ChannelPostPolicy channelPostPolicy;
ChannelPostPolicy channelPostPolicy;
// final bool isAnnouncementOnly; // deprecated for `channelPostPolicy`; ignore

// TODO(server-6): `canRemoveSubscribersGroupId` added in FL 142
// TODO(server-8): in FL 197 renamed to `canRemoveSubscribersGroup`
@JsonKey(readValue: _readCanRemoveSubscribersGroup)
final int? canRemoveSubscribersGroup;
int? canRemoveSubscribersGroup;

// TODO(server-8): added in FL 199, was previously only on [Subscription] objects
final int? streamWeeklyTraffic;
int? streamWeeklyTraffic;

static int? _readCanRemoveSubscribersGroup(Map<dynamic, dynamic> json, String key) {
return (json[key] as int?)
Expand Down Expand Up @@ -363,6 +371,41 @@ class ZulipStream {
Map<String, dynamic> toJson() => _$ZulipStreamToJson(this);
}

/// The name of a property of [ZulipStream] that gets updated
/// through [ChannelUpdateEvent.property].
///
/// In Zulip event-handling code (for [ChannelUpdateEvent]),
/// we switch exhaustively on a value of this type
/// to ensure that every property in [ZulipStream] responds to the event.
@JsonEnum(fieldRename: FieldRename.snake, alwaysCreate: true)
enum ChannelPropertyName {
// streamId is immutable
name,
description,
// renderedDescription is updated via its own [ChannelUpdateEvent] field
// dateCreated is immutable
firstMessageId,
inviteOnly,
// isWebPublic is updated via its own [ChannelUpdateEvent] field
// historyPublicToSubscribers is updated via its own [ChannelUpdateEvent] field
messageRetentionDays,
@JsonValue('stream_post_policy')
channelPostPolicy,
canRemoveSubscribersGroup,
canRemoveSubscribersGroupId, // TODO(server-8): remove, replaced by canRemoveSubscribersGroup
streamWeeklyTraffic;

/// Get a [ChannelPropertyName] from a raw, snake-case string we recognize, else null.
///
/// Example:
/// 'invite_only' -> ChannelPropertyName.inviteOnly
static ChannelPropertyName? fromRawString(String raw) => _byRawString[raw];

// _$…EnumMap is thanks to `alwaysCreate: true` and `fieldRename: FieldRename.snake`
static final _byRawString = _$ChannelPropertyNameEnumMap
.map((key, value) => MapEntry(value, key));
}

/// Policy for which users can post to the stream.
///
/// For docs, search for "stream_post_policy"
Expand All @@ -382,6 +425,11 @@ enum ChannelPostPolicy {
final int? apiValue;

int? toJson() => apiValue;

static ChannelPostPolicy fromApiValue(int value) => _byApiValue[value]!;

static final _byApiValue = _$ChannelPostPolicyEnumMap
.map((key, value) => MapEntry(value, key));
}

/// As in `subscriptions` in the initial snapshot.
Expand Down
13 changes: 13 additions & 0 deletions lib/api/model/model.g.dart

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

44 changes: 44 additions & 0 deletions lib/model/channel.dart
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,50 @@ class ChannelStoreImpl with ChannelStore {
streamsByName.remove(stream.name);
subscriptions.remove(stream.streamId);
}

case ChannelUpdateEvent():
final stream = streams[event.streamId];
if (stream == null) return; // TODO(log)
assert(stream.streamId == event.streamId);

if (event.renderedDescription != null) {
stream.renderedDescription = event.renderedDescription!;
}
if (event.historyPublicToSubscribers != null) {
stream.historyPublicToSubscribers = event.historyPublicToSubscribers!;
}
if (event.isWebPublic != null) {
stream.isWebPublic = event.isWebPublic!;
}

if (event.property == null) {
// unrecognized property; do nothing
return;
}
switch (event.property!) {
case ChannelPropertyName.name:
final streamName = stream.name;
assert(streamName == event.name);
assert(identical(streams[stream.streamId], streamsByName[streamName]));
stream.name = event.value as String;
streamsByName.remove(streamName);
streamsByName[stream.name] = stream;
case ChannelPropertyName.description:
stream.description = event.value as String;
case ChannelPropertyName.firstMessageId:
stream.firstMessageId = event.value as int?;
case ChannelPropertyName.inviteOnly:
stream.inviteOnly = event.value as bool;
case ChannelPropertyName.messageRetentionDays:
stream.messageRetentionDays = event.value as int?;
case ChannelPropertyName.channelPostPolicy:
stream.channelPostPolicy = event.value as ChannelPostPolicy;
case ChannelPropertyName.canRemoveSubscribersGroup:
case ChannelPropertyName.canRemoveSubscribersGroupId:
stream.canRemoveSubscribersGroup = event.value as int?;
case ChannelPropertyName.streamWeeklyTraffic:
stream.streamWeeklyTraffic = event.value as int?;
}
}
}

Expand Down
31 changes: 31 additions & 0 deletions test/example_data.dart
Original file line number Diff line number Diff line change
Expand Up @@ -655,6 +655,37 @@ ReactionEvent reactionEvent(Reaction reaction, ReactionOp op, int messageId) {
);
}

ChannelUpdateEvent channelUpdateEvent(
ZulipStream stream, {
required ChannelPropertyName property,
required Object? value,
}) {
switch (property) {
case ChannelPropertyName.name:
case ChannelPropertyName.description:
assert(value is String);
case ChannelPropertyName.firstMessageId:
assert(value is int?);
case ChannelPropertyName.inviteOnly:
assert(value is bool);
case ChannelPropertyName.messageRetentionDays:
assert(value is int?);
case ChannelPropertyName.channelPostPolicy:
assert(value is ChannelPostPolicy);
case ChannelPropertyName.canRemoveSubscribersGroup:
case ChannelPropertyName.canRemoveSubscribersGroupId:
case ChannelPropertyName.streamWeeklyTraffic:
assert(value is int?);
}
return ChannelUpdateEvent(
id: 1,
streamId: stream.streamId,
name: stream.name,
property: property,
value: value,
);
}

////////////////////////////////////////////////////////////////
// The entire per-account or global state.
//
Expand Down
13 changes: 12 additions & 1 deletion test/model/channel_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ void main() {
)));
});

test('added by events', () async {
test('added/updated by events', () async {
final stream1 = eg.stream();
final stream2 = eg.stream();
final store = eg.store();
Expand All @@ -56,6 +56,17 @@ void main() {

await store.addSubscription(eg.subscription(stream1));
checkUnified(store);

await store.handleEvent(eg.channelUpdateEvent(store.streams[stream1.streamId]!,
Copy link
Collaborator

Choose a reason for hiding this comment

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

If adding this to the 'added by events' test, maybe good to rename the test to 'added/updated by events'.

property: ChannelPropertyName.name, value: 'new stream',
));
checkUnified(store);

await store.handleEvent(eg.channelUpdateEvent(store.streams[stream1.streamId]!,
property: ChannelPropertyName.channelPostPolicy,
value: ChannelPostPolicy.administrators,
));
checkUnified(store);
});
});

Expand Down