diff --git a/lib/api/model/events.dart b/lib/api/model/events.dart index 0479b0428f..a5174db4f7 100644 --- a/lib/api/model/events.dart +++ b/lib/api/model/events.dart @@ -37,6 +37,13 @@ sealed class Event { case 'update': return RealmUserUpdateEvent.fromJson(json); default: return UnexpectedEvent.fromJson(json); } + case 'saved_snippets': + switch (json['op'] as String) { + case 'add': return SavedSnippetsAddEvent.fromJson(json); + case 'update': return SavedSnippetsUpdateEvent.fromJson(json); + case 'remove': return SavedSnippetsRemoveEvent.fromJson(json); + default: return UnexpectedEvent.fromJson(json); + } case 'stream': switch (json['op'] as String) { case 'create': return ChannelCreateEvent.fromJson(json); @@ -336,6 +343,71 @@ class RealmUserUpdateEvent extends RealmUserEvent { Map toJson() => _$RealmUserUpdateEventToJson(this); } +/// A Zulip event of type `saved_snippets`. +/// +/// The corresponding API docs are in several places for +/// different values of `op`; see subclasses. +sealed class SavedSnippetsEvent extends Event { + @override + @JsonKey(includeToJson: true) + String get type => 'saved_snippets'; + + String get op; + + SavedSnippetsEvent({required super.id}); +} + +/// A [SavedSnippetsEvent] with op `add`: https://zulip.com/api/get-events#saved_snippets-add +@JsonSerializable(fieldRename: FieldRename.snake) +class SavedSnippetsAddEvent extends SavedSnippetsEvent { + @override + String get op => 'add'; + + final SavedSnippet savedSnippet; + + SavedSnippetsAddEvent({required super.id, required this.savedSnippet}); + + factory SavedSnippetsAddEvent.fromJson(Map json) => + _$SavedSnippetsAddEventFromJson(json); + + @override + Map toJson() => _$SavedSnippetsAddEventToJson(this); +} + +/// A [SavedSnippetsEvent] with op `update`: https://zulip.com/api/get-events#saved_snippets-update +@JsonSerializable(fieldRename: FieldRename.snake) +class SavedSnippetsUpdateEvent extends SavedSnippetsEvent { + @override + String get op => 'update'; + + final SavedSnippet savedSnippet; + + SavedSnippetsUpdateEvent({required super.id, required this.savedSnippet}); + + factory SavedSnippetsUpdateEvent.fromJson(Map json) => + _$SavedSnippetsUpdateEventFromJson(json); + + @override + Map toJson() => _$SavedSnippetsUpdateEventToJson(this); +} + +/// A [SavedSnippetsEvent] with op `remove`: https://zulip.com/api/get-events#saved_snippets-remove +@JsonSerializable(fieldRename: FieldRename.snake) +class SavedSnippetsRemoveEvent extends SavedSnippetsEvent { + @override + String get op => 'remove'; + + final int savedSnippetId; + + SavedSnippetsRemoveEvent({required super.id, required this.savedSnippetId}); + + factory SavedSnippetsRemoveEvent.fromJson(Map json) => + _$SavedSnippetsRemoveEventFromJson(json); + + @override + Map toJson() => _$SavedSnippetsRemoveEventToJson(this); +} + /// A Zulip event of type `stream`. /// /// The corresponding API docs are in several places for diff --git a/lib/api/model/events.g.dart b/lib/api/model/events.g.dart index 35206d77b9..94fe288150 100644 --- a/lib/api/model/events.g.dart +++ b/lib/api/model/events.g.dart @@ -203,6 +203,55 @@ Json? _$JsonConverterToJson( Json? Function(Value value) toJson, ) => value == null ? null : toJson(value); +SavedSnippetsAddEvent _$SavedSnippetsAddEventFromJson( + Map json, +) => SavedSnippetsAddEvent( + id: (json['id'] as num).toInt(), + savedSnippet: SavedSnippet.fromJson( + json['saved_snippet'] as Map, + ), +); + +Map _$SavedSnippetsAddEventToJson( + SavedSnippetsAddEvent instance, +) => { + 'id': instance.id, + 'type': instance.type, + 'saved_snippet': instance.savedSnippet, +}; + +SavedSnippetsUpdateEvent _$SavedSnippetsUpdateEventFromJson( + Map json, +) => SavedSnippetsUpdateEvent( + id: (json['id'] as num).toInt(), + savedSnippet: SavedSnippet.fromJson( + json['saved_snippet'] as Map, + ), +); + +Map _$SavedSnippetsUpdateEventToJson( + SavedSnippetsUpdateEvent instance, +) => { + 'id': instance.id, + 'type': instance.type, + 'saved_snippet': instance.savedSnippet, +}; + +SavedSnippetsRemoveEvent _$SavedSnippetsRemoveEventFromJson( + Map json, +) => SavedSnippetsRemoveEvent( + id: (json['id'] as num).toInt(), + savedSnippetId: (json['saved_snippet_id'] as num).toInt(), +); + +Map _$SavedSnippetsRemoveEventToJson( + SavedSnippetsRemoveEvent instance, +) => { + 'id': instance.id, + 'type': instance.type, + 'saved_snippet_id': instance.savedSnippetId, +}; + ChannelCreateEvent _$ChannelCreateEventFromJson(Map json) => ChannelCreateEvent( id: (json['id'] as num).toInt(), diff --git a/lib/api/model/initial_snapshot.dart b/lib/api/model/initial_snapshot.dart index 054230a256..f4cc2fe5fc 100644 --- a/lib/api/model/initial_snapshot.dart +++ b/lib/api/model/initial_snapshot.dart @@ -48,6 +48,8 @@ class InitialSnapshot { final List recentPrivateConversations; + final List? savedSnippets; // TODO(server-10) + final List subscriptions; final UnreadMessagesSnapshot unreadMsgs; @@ -132,6 +134,7 @@ class InitialSnapshot { required this.serverTypingStartedWaitPeriodMilliseconds, required this.realmEmoji, required this.recentPrivateConversations, + required this.savedSnippets, required this.subscriptions, required this.unreadMsgs, required this.streams, diff --git a/lib/api/model/initial_snapshot.g.dart b/lib/api/model/initial_snapshot.g.dart index 570d7c2bba..36afb0a39f 100644 --- a/lib/api/model/initial_snapshot.g.dart +++ b/lib/api/model/initial_snapshot.g.dart @@ -45,6 +45,10 @@ InitialSnapshot _$InitialSnapshotFromJson( (json['recent_private_conversations'] as List) .map((e) => RecentDmConversation.fromJson(e as Map)) .toList(), + savedSnippets: + (json['saved_snippets'] as List?) + ?.map((e) => SavedSnippet.fromJson(e as Map)) + .toList(), subscriptions: (json['subscriptions'] as List) .map((e) => Subscription.fromJson(e as Map)) @@ -128,6 +132,7 @@ Map _$InitialSnapshotToJson(InitialSnapshot instance) => instance.serverTypingStartedWaitPeriodMilliseconds, 'realm_emoji': instance.realmEmoji, 'recent_private_conversations': instance.recentPrivateConversations, + 'saved_snippets': instance.savedSnippets, 'subscriptions': instance.subscriptions, 'unread_msgs': instance.unreadMsgs, 'streams': instance.streams, diff --git a/lib/api/model/model.dart b/lib/api/model/model.dart index a2874c4c44..131a51991b 100644 --- a/lib/api/model/model.dart +++ b/lib/api/model/model.dart @@ -311,6 +311,30 @@ enum UserRole{ } } +/// An item in `saved_snippets` from the initial snapshot. +/// +/// For docs, search for "saved_snippets:" +/// in . +@JsonSerializable(fieldRename: FieldRename.snake) +class SavedSnippet { + SavedSnippet({ + required this.id, + required this.title, + required this.content, + required this.dateCreated, + }); + + final int id; + final String title; + final String content; + final int dateCreated; + + factory SavedSnippet.fromJson(Map json) => + _$SavedSnippetFromJson(json); + + Map toJson() => _$SavedSnippetToJson(this); +} + /// As in `streams` in the initial snapshot. /// /// Not called `Stream` because dart:async uses that name. diff --git a/lib/api/model/model.g.dart b/lib/api/model/model.g.dart index cddf78beb0..67fc606031 100644 --- a/lib/api/model/model.g.dart +++ b/lib/api/model/model.g.dart @@ -162,6 +162,21 @@ Map _$ProfileFieldUserDataToJson( 'rendered_value': instance.renderedValue, }; +SavedSnippet _$SavedSnippetFromJson(Map json) => SavedSnippet( + id: (json['id'] as num).toInt(), + title: json['title'] as String, + content: json['content'] as String, + dateCreated: (json['date_created'] as num).toInt(), +); + +Map _$SavedSnippetToJson(SavedSnippet instance) => + { + 'id': instance.id, + 'title': instance.title, + 'content': instance.content, + 'date_created': instance.dateCreated, + }; + ZulipStream _$ZulipStreamFromJson(Map json) => ZulipStream( streamId: (json['stream_id'] as num).toInt(), name: json['name'] as String, diff --git a/lib/api/route/saved_snippets.dart b/lib/api/route/saved_snippets.dart new file mode 100644 index 0000000000..b0556b2bf5 --- /dev/null +++ b/lib/api/route/saved_snippets.dart @@ -0,0 +1,30 @@ +import 'package:json_annotation/json_annotation.dart'; + +import '../core.dart'; + +part 'saved_snippets.g.dart'; + +/// https://zulip.com/api/create-saved-snippet +Future createSavedSnippet(ApiConnection connection, { + required String title, + required String content, +}) { + return connection.post('createSavedSnippet', CreateSavedSnippetResult.fromJson, 'saved_snippets', { + 'title': RawParameter(title), + 'content': RawParameter(content), + }); +} + +@JsonSerializable(fieldRename: FieldRename.snake) +class CreateSavedSnippetResult { + final int savedSnippetId; + + CreateSavedSnippetResult({ + required this.savedSnippetId, + }); + + factory CreateSavedSnippetResult.fromJson(Map json) => + _$CreateSavedSnippetResultFromJson(json); + + Map toJson() => _$CreateSavedSnippetResultToJson(this); +} diff --git a/lib/api/route/saved_snippets.g.dart b/lib/api/route/saved_snippets.g.dart new file mode 100644 index 0000000000..aeb3c2a6c5 --- /dev/null +++ b/lib/api/route/saved_snippets.g.dart @@ -0,0 +1,19 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +// ignore_for_file: constant_identifier_names, unnecessary_cast + +part of 'saved_snippets.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +CreateSavedSnippetResult _$CreateSavedSnippetResultFromJson( + Map json, +) => CreateSavedSnippetResult( + savedSnippetId: (json['saved_snippet_id'] as num).toInt(), +); + +Map _$CreateSavedSnippetResultToJson( + CreateSavedSnippetResult instance, +) => {'saved_snippet_id': instance.savedSnippetId}; diff --git a/lib/model/saved_snippet.dart b/lib/model/saved_snippet.dart new file mode 100644 index 0000000000..59c8347591 --- /dev/null +++ b/lib/model/saved_snippet.dart @@ -0,0 +1,38 @@ +import 'package:collection/collection.dart'; + +import '../api/model/events.dart'; +import '../api/model/model.dart'; +import 'store.dart'; + +mixin SavedSnippetStore { + Map get savedSnippets; +} + +class SavedSnippetStoreImpl extends PerAccountStoreBase with SavedSnippetStore { + SavedSnippetStoreImpl({ + required super.core, + required Iterable savedSnippets, + }) : _savedSnippets = { + for (final savedSnippet in savedSnippets) + savedSnippet.id: savedSnippet, + }; + + @override + late Map savedSnippets = UnmodifiableMapView(_savedSnippets); + final Map _savedSnippets; + + void handleSavedSnippetsEvent(SavedSnippetsEvent event) { + switch (event) { + case SavedSnippetsAddEvent(:final savedSnippet): + _savedSnippets[savedSnippet.id] = savedSnippet; + + case SavedSnippetsUpdateEvent(:final savedSnippet): + assert(_savedSnippets[savedSnippet.id]!.dateCreated + == savedSnippet.dateCreated); // TODO(log) + _savedSnippets[savedSnippet.id] = savedSnippet; + + case SavedSnippetsRemoveEvent(:final savedSnippetId): + _savedSnippets.remove(savedSnippetId); + } + } +} diff --git a/lib/model/store.dart b/lib/model/store.dart index 5f5b19128b..e535460fc4 100644 --- a/lib/model/store.dart +++ b/lib/model/store.dart @@ -29,6 +29,7 @@ import 'message_list.dart'; import 'recent_dm_conversations.dart'; import 'recent_senders.dart'; import 'channel.dart'; +import 'saved_snippet.dart'; import 'settings.dart'; import 'typing_status.dart'; import 'unreads.dart'; @@ -431,7 +432,7 @@ Uri? tryResolveUrl(Uri baseUrl, String reference) { /// This class does not attempt to poll an event queue /// to keep the data up to date. For that behavior, see /// [UpdateMachine]. -class PerAccountStore extends PerAccountStoreBase with ChangeNotifier, EmojiStore, UserStore, ChannelStore, MessageStore { +class PerAccountStore extends PerAccountStoreBase with ChangeNotifier, EmojiStore, SavedSnippetStore, UserStore, ChannelStore, MessageStore { /// Construct a store for the user's data, starting from the given snapshot. /// /// The global store must already have been updated with @@ -485,6 +486,8 @@ class PerAccountStore extends PerAccountStoreBase with ChangeNotifier, EmojiStor emailAddressVisibility: initialSnapshot.emailAddressVisibility, emoji: EmojiStoreImpl( core: core, allRealmEmoji: initialSnapshot.realmEmoji), + savedSnippets: SavedSnippetStoreImpl( + core: core, savedSnippets: initialSnapshot.savedSnippets ?? []), userSettings: initialSnapshot.userSettings, typingNotifier: TypingNotifier( core: core, @@ -523,6 +526,7 @@ class PerAccountStore extends PerAccountStoreBase with ChangeNotifier, EmojiStor required this.customProfileFields, required this.emailAddressVisibility, required EmojiStoreImpl emoji, + required SavedSnippetStoreImpl savedSnippets, required this.userSettings, required this.typingNotifier, required UserStoreImpl users, @@ -534,6 +538,7 @@ class PerAccountStore extends PerAccountStoreBase with ChangeNotifier, EmojiStor required this.recentSenders, }) : _realmEmptyTopicDisplayName = realmEmptyTopicDisplayName, _emoji = emoji, + _savedSnippets = savedSnippets, _users = users, _channels = channels, _messages = messages; @@ -619,6 +624,10 @@ class PerAccountStore extends PerAccountStoreBase with ChangeNotifier, EmojiStor //////////////////////////////// // Data attached to the self-account on the realm. + @override + Map get savedSnippets => _savedSnippets.savedSnippets; + final SavedSnippetStoreImpl _savedSnippets; + final UserSettings? userSettings; // TODO(server-5) final TypingNotifier typingNotifier; @@ -868,6 +877,11 @@ class PerAccountStore extends PerAccountStoreBase with ChangeNotifier, EmojiStor autocompleteViewManager.handleRealmUserUpdateEvent(event); notifyListeners(); + case SavedSnippetsEvent(): + assert(debugLog('server event: saved_snippets/${event.op}')); + _savedSnippets.handleSavedSnippetsEvent(event); + notifyListeners(); + case ChannelEvent(): assert(debugLog("server event: stream/${event.op}")); _channels.handleChannelEvent(event); diff --git a/test/api/model/model_checks.dart b/test/api/model/model_checks.dart index 1a17f70f60..d9f42b9911 100644 --- a/test/api/model/model_checks.dart +++ b/test/api/model/model_checks.dart @@ -21,6 +21,12 @@ extension UserChecks on Subject { Subject get isSystemBot => has((x) => x.isSystemBot, 'isSystemBot'); } +extension SavedSnippetChecks on Subject { + Subject get id => has((x) => x.id, 'id'); + Subject get title => has((x) => x.title, 'title'); + Subject get content => has((x) => x.content, 'content'); +} + extension ZulipStreamChecks on Subject { } diff --git a/test/api/model/saved_snippets_test.dart b/test/api/model/saved_snippets_test.dart new file mode 100644 index 0000000000..cb97970472 --- /dev/null +++ b/test/api/model/saved_snippets_test.dart @@ -0,0 +1,25 @@ +import 'package:checks/checks.dart'; +import 'package:http/http.dart' as http; +import 'package:flutter_test/flutter_test.dart'; +import 'package:zulip/api/route/saved_snippets.dart'; + +import '../../stdlib_checks.dart'; +import '../fake_api.dart'; +import '../route/route_checks.dart'; + +void main() { + test('smoke', () async { + return FakeApiConnection.with_((connection) async { + connection.prepare( + json: CreateSavedSnippetResult(savedSnippetId: 123).toJson()); + final result = await createSavedSnippet(connection, + title: 'test saved snippet', content: 'content'); + check(result).savedSnippetId.equals(123); + check(connection.takeRequests()).single.isA() + .bodyFields.deepEquals({ + 'title': 'test saved snippet', + 'content': 'content', + }); + }); + }); +} diff --git a/test/api/route/route_checks.dart b/test/api/route/route_checks.dart index 6d310ab200..1ecd90e9c8 100644 --- a/test/api/route/route_checks.dart +++ b/test/api/route/route_checks.dart @@ -1,8 +1,12 @@ import 'package:checks/checks.dart'; import 'package:zulip/api/route/messages.dart'; +import 'package:zulip/api/route/saved_snippets.dart'; extension SendMessageResultChecks on Subject { Subject get id => has((e) => e.id, 'id'); } +extension CreateSavedSnippetResultChecks on Subject { + Subject get savedSnippetId => has((e) => e.savedSnippetId, 'savedSnippetId'); +} // TODO add similar extensions for other classes in api/route/*.dart diff --git a/test/example_data.dart b/test/example_data.dart index e0f44f9ddc..bc1ad1cf05 100644 --- a/test/example_data.dart +++ b/test/example_data.dart @@ -250,6 +250,28 @@ final User thirdUser = user(fullName: 'Third User'); final User fourthUser = user(fullName: 'Fourth User'); +//////////////////////////////////////////////////////////////// +// Data attached to the self-account on the realm +// + +int _nextSavedSnippetId() => _lastSavedSnippetId++; +int _lastSavedSnippetId = 1; + +SavedSnippet savedSnippet({ + int? id, + String? title, + String? content, + int? dateCreated, +}) { + _checkPositive(id, 'saved snippet ID'); + return SavedSnippet( + id: id ?? _nextSavedSnippetId(), + title: title ?? 'A saved snippet', + content: content ?? 'foo bar baz', + dateCreated: dateCreated ?? 1234567890, // TODO generate timestamp + ); +} + //////////////////////////////////////////////////////////////// // Streams and subscriptions. // @@ -923,6 +945,7 @@ InitialSnapshot initialSnapshot({ int? serverTypingStartedWaitPeriodMilliseconds, Map? realmEmoji, List? recentPrivateConversations, + List? savedSnippets, List? subscriptions, UnreadMessagesSnapshot? unreadMsgs, List? streams, @@ -958,6 +981,7 @@ InitialSnapshot initialSnapshot({ serverTypingStartedWaitPeriodMilliseconds ?? 10000, realmEmoji: realmEmoji ?? {}, recentPrivateConversations: recentPrivateConversations ?? [], + savedSnippets: savedSnippets ?? [], subscriptions: subscriptions ?? [], // TODO add subscriptions to default unreadMsgs: unreadMsgs ?? _unreadMsgs(), streams: streams ?? [], // TODO add streams to default diff --git a/test/model/saved_snippet.dart b/test/model/saved_snippet.dart new file mode 100644 index 0000000000..3c6756f977 --- /dev/null +++ b/test/model/saved_snippet.dart @@ -0,0 +1,44 @@ +import 'package:checks/checks.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:zulip/api/model/events.dart'; +import 'package:zulip/api/model/model.dart'; + +import '../api/model/model_checks.dart'; +import '../example_data.dart' as eg; +import 'store_checks.dart'; + +void main() { + test('handleSavedSnippetsEvent', () async { + final store = eg.store(initialSnapshot: eg.initialSnapshot( + savedSnippets: [eg.savedSnippet(id: 101)])); + check(store).savedSnippets.values.single.id.equals(101); + + await store.handleEvent(SavedSnippetsAddEvent(id: 1, + savedSnippet: eg.savedSnippet( + id: 102, + title: 'foo title', + content: 'foo content', + ))); + check(store).savedSnippets.values.deepEquals(>[ + (it) => it.isA().id.equals(101), + (it) => it.isA()..id.equals(102) + ..title.equals('foo title') + ..content.equals('foo content') + ]); + + await store.handleEvent(SavedSnippetsRemoveEvent(id: 1, savedSnippetId: 101)); + check(store).savedSnippets.values.single.id.equals(102); + + await store.handleEvent(SavedSnippetsUpdateEvent(id: 1, + savedSnippet: eg.savedSnippet( + id: 102, + title: 'bar title', + content: 'bar content', + dateCreated: store.savedSnippets.values.single.dateCreated, + ))); + check(store).savedSnippets.values.single + ..id.equals(102) + ..title.equals('bar title') + ..content.equals('bar content'); + }); +} diff --git a/test/model/store_checks.dart b/test/model/store_checks.dart index 32379a6f06..b3d1bc8820 100644 --- a/test/model/store_checks.dart +++ b/test/model/store_checks.dart @@ -55,6 +55,7 @@ extension PerAccountStoreChecks on Subject { Subject get accountId => has((x) => x.accountId, 'accountId'); Subject get account => has((x) => x.account, 'account'); Subject get selfUserId => has((x) => x.selfUserId, 'selfUserId'); + Subject> get savedSnippets => has((x) => x.savedSnippets, 'savedSnippets'); Subject get userSettings => has((x) => x.userSettings, 'userSettings'); Subject> get streams => has((x) => x.streams, 'streams'); Subject> get streamsByName => has((x) => x.streamsByName, 'streamsByName');