diff --git a/lib/api/model/narrow.dart b/lib/api/model/narrow.dart new file mode 100644 index 0000000000..b944936549 --- /dev/null +++ b/lib/api/model/narrow.dart @@ -0,0 +1,60 @@ +typedef ApiNarrow = List; + +/// An element in the list representing a narrow in the Zulip API. +/// +/// Docs: +/// +/// The existing list of subclasses is incomplete; +/// please add more as needed. +sealed class ApiNarrowElement { + String get operator; + Object get operand; + final bool negated; + + ApiNarrowElement({this.negated = false}); + + Map toJson() => { + 'operator': operator, + 'operand': operand, + if (negated) 'negated': negated, + }; +} + +class ApiNarrowStream extends ApiNarrowElement { + @override String get operator => 'stream'; + + @override final int operand; + + ApiNarrowStream(this.operand, {super.negated}); + + factory ApiNarrowStream.fromJson(Map json) => ApiNarrowStream( + json['operand'] as int, + negated: json['negated'] as bool? ?? false, + ); +} + +class ApiNarrowTopic extends ApiNarrowElement { + @override String get operator => 'topic'; + + @override final String operand; + + ApiNarrowTopic(this.operand, {super.negated}); + + factory ApiNarrowTopic.fromJson(Map json) => ApiNarrowTopic( + json['operand'] as String, + negated: json['negated'] as bool? ?? false, + ); +} + +class ApiNarrowPmWith extends ApiNarrowElement { + @override String get operator => 'pm-with'; // TODO(server-7): use 'dm' where possible + + @override final List operand; + + ApiNarrowPmWith(this.operand, {super.negated}); + + factory ApiNarrowPmWith.fromJson(Map json) => ApiNarrowPmWith( + (json['operand'] as List).map((e) => e as int).toList(), + negated: json['negated'] as bool? ?? false, + ); +} diff --git a/lib/api/route/messages.dart b/lib/api/route/messages.dart index a616f84391..20461891c1 100644 --- a/lib/api/route/messages.dart +++ b/lib/api/route/messages.dart @@ -2,22 +2,56 @@ import 'package:json_annotation/json_annotation.dart'; import '../core.dart'; import '../model/model.dart'; +import '../model/narrow.dart'; part 'messages.g.dart'; /// https://zulip.com/api/get-messages Future getMessages(ApiConnection connection, { + required ApiNarrow narrow, + required Anchor anchor, + bool? includeAnchor, required int numBefore, required int numAfter, + bool? clientGravatar, + bool? applyMarkdown, + // bool? useFirstUnreadAnchor // omitted because deprecated }) { return connection.get('getMessages', GetMessagesResult.fromJson, 'messages', { - // 'narrow': [], // TODO parametrize - 'anchor': 999999999, // TODO parametrize; use RawParameter for strings + 'narrow': narrow, + 'anchor': switch (anchor) { + NumericAnchor(:var messageId) => messageId, + AnchorCode.newest => RawParameter('newest'), + AnchorCode.oldest => RawParameter('oldest'), + AnchorCode.firstUnread => RawParameter('first_unread'), + }, + if (includeAnchor != null) 'include_anchor': includeAnchor, 'num_before': numBefore, 'num_after': numAfter, + if (clientGravatar != null) 'client_gravatar': clientGravatar, + if (applyMarkdown != null) 'apply_markdown': applyMarkdown, }); } +/// An anchor value for [getMessages]. +/// +/// https://zulip.com/api/get-messages#parameter-anchor +sealed class Anchor { + /// This const constructor allows subclasses to have const constructors. + const Anchor(); +} + +/// An anchor value for [getMessages] other than a specific message ID. +/// +/// https://zulip.com/api/get-messages#parameter-anchor +enum AnchorCode implements Anchor { newest, oldest, firstUnread } + +/// A specific message ID, used as an anchor in [getMessages]. +class NumericAnchor extends Anchor { + const NumericAnchor(this.messageId); + final int messageId; +} + @JsonSerializable(fieldRename: FieldRename.snake) class GetMessagesResult { final int anchor; @@ -58,26 +92,56 @@ const int kMaxMessageLengthCodePoints = 10000; const String kNoTopicTopic = '(no topic)'; /// https://zulip.com/api/send-message -// TODO currently only handles stream messages; fix Future sendMessage( ApiConnection connection, { + required MessageDestination destination, required String content, - required String topic, + String? queueId, + String? localId, }) { - // assert() is less verbose but would have no effect in production, I think: - // https://dart.dev/guides/language/language-tour#assert - if (connection.realmUrl.origin != 'https://chat.zulip.org') { - throw Exception('This binding can currently only be used on https://chat.zulip.org.'); - } - return connection.post('sendMessage', SendMessageResult.fromJson, 'messages', { - 'type': RawParameter('stream'), // TODO parametrize - 'to': 7, // TODO parametrize; this is `#test here` - 'topic': RawParameter(topic), + if (destination is StreamDestination) ...{ + 'type': RawParameter('stream'), + 'to': destination.streamId, + 'topic': RawParameter(destination.topic), + } else if (destination is PmDestination) ...{ + 'type': RawParameter('private'), // TODO(server-7) + 'to': destination.userIds, + } else ...( + throw Exception('impossible destination') // TODO(dart-3) show this statically + ), 'content': RawParameter(content), + if (queueId != null) 'queue_id': queueId, + if (localId != null) 'local_id': localId, }); } +/// Which conversation to send a message to, in [sendMessage]. +/// +/// This is either a [StreamDestination] or a [PmDestination]. +sealed class MessageDestination {} + +/// A conversation in a stream, for specifying to [sendMessage]. +/// +/// The server accepts a stream name as an alternative to a stream ID, +/// but this binding currently doesn't. +class StreamDestination extends MessageDestination { + StreamDestination(this.streamId, this.topic); + + final int streamId; + final String topic; +} + +/// A PM conversation, for specifying to [sendMessage]. +/// +/// The server accepts a list of Zulip API emails as an alternative to +/// a list of user IDs, but this binding currently doesn't. +class PmDestination extends MessageDestination { + PmDestination({required this.userIds}); + + final List userIds; +} + @JsonSerializable(fieldRename: FieldRename.snake) class SendMessageResult { final int id; diff --git a/lib/model/message_list.dart b/lib/model/message_list.dart index 6fb84a9837..cb7fbeb3e9 100644 --- a/lib/model/message_list.dart +++ b/lib/model/message_list.dart @@ -58,6 +58,8 @@ class MessageListView extends ChangeNotifier { assert(contents.isEmpty); // TODO schedule all this in another isolate final result = await getMessages(store.connection, + narrow: narrow.apiEncode(), + anchor: AnchorCode.newest, // TODO(#80): switch to firstUnread numBefore: 100, numAfter: 10, ); diff --git a/lib/model/narrow.dart b/lib/model/narrow.dart index 1add4197b0..508e843b02 100644 --- a/lib/model/narrow.dart +++ b/lib/model/narrow.dart @@ -1,12 +1,18 @@ import '../api/model/model.dart'; +import '../api/model/narrow.dart'; /// A Zulip narrow. -abstract class Narrow { +sealed class Narrow { /// This const constructor allows subclasses to have const constructors. const Narrow(); + // TODO implement muting; will need containsMessage to take more params + // This means stream muting, topic un/muting, and user muting. bool containsMessage(Message message); + + /// This narrow, expressed as an [ApiNarrow]. + ApiNarrow apiEncode(); } /// The narrow called "All messages" in the UI. @@ -19,14 +25,14 @@ class AllMessagesNarrow extends Narrow { @override bool containsMessage(Message message) { - // TODO implement muting; will need containsMessage to take more params return true; } + @override + ApiNarrow apiEncode() => []; + @override bool operator ==(Object other) { - // Conceptually this is a sealed class, so equality is simplified. - // TODO(dart-3): Make this actually a sealed class. if (other is! AllMessagesNarrow) return false; // Conceptually there's only one value of this type. return true; @@ -36,4 +42,52 @@ class AllMessagesNarrow extends Narrow { int get hashCode => 'AllMessagesNarrow'.hashCode; } -// TODO other narrow types +class StreamNarrow extends Narrow { + const StreamNarrow(this.streamId); + + final int streamId; + + @override + bool containsMessage(Message message) { + return message is StreamMessage && message.streamId == streamId; + } + + @override + ApiNarrow apiEncode() => [ApiNarrowStream(streamId)]; + + @override + bool operator ==(Object other) { + if (other is! StreamNarrow) return false; + return other.streamId == streamId; + } + + @override + int get hashCode => Object.hash('StreamNarrow', streamId); +} + +class TopicNarrow extends Narrow { + const TopicNarrow(this.streamId, this.topic); + + final int streamId; + final String topic; + + @override + bool containsMessage(Message message) { + return (message is StreamMessage + && message.streamId == streamId && message.subject == topic); + } + + @override + ApiNarrow apiEncode() => [ApiNarrowStream(streamId), ApiNarrowTopic(topic)]; + + @override + bool operator ==(Object other) { + if (other is! TopicNarrow) return false; + return other.streamId == streamId && other.topic == topic; + } + + @override + int get hashCode => Object.hash('TopicNarrow', streamId, topic); +} + +// TODO other narrow types: PMs/DMs; starred, mentioned; searches; arbitrary diff --git a/lib/model/store.dart b/lib/model/store.dart index 9534740b84..6ea7ef178b 100644 --- a/lib/model/store.dart +++ b/lib/model/store.dart @@ -248,13 +248,15 @@ class PerAccountStore extends ChangeNotifier { } } - Future sendStreamMessage({required String topic, required String content}) { + Future sendMessage({required MessageDestination destination, required String content}) { // TODO implement outbox; see design at // https://chat.zulip.org/#narrow/stream/243-mobile-team/topic/.23M3881.20Sending.20outbox.20messages.20is.20fraught.20with.20issues/near/1405739 - return sendMessage(connection, topic: topic, content: content); + return _apiSendMessage(connection, destination: destination, content: content); } } +const _apiSendMessage = sendMessage; // Bit ugly; for alternatives, see: https://chat.zulip.org/#narrow/stream/243-mobile-team/topic/flutter.3A.20PerAccountStore.20methods/near/1545809 + /// A [GlobalStore] that uses a live server and live, persistent local database. /// /// The underlying data store is an [AppDatabase] corresponding to a SQLite diff --git a/lib/widgets/app.dart b/lib/widgets/app.dart index 13bb241333..748c43ed96 100644 --- a/lib/widgets/app.dart +++ b/lib/widgets/app.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; +import '../model/narrow.dart'; import 'about_zulip.dart'; import 'compose_box.dart'; import 'login.dart'; @@ -143,20 +144,23 @@ class HomePage extends StatelessWidget { const SizedBox(height: 16), ElevatedButton( onPressed: () => Navigator.push(context, - MessageListPage.buildRoute(context)), + MessageListPage.buildRoute(context: context, + narrow: const AllMessagesNarrow())), child: const Text("All messages")), ]))); } } class MessageListPage extends StatelessWidget { - const MessageListPage({super.key}); + const MessageListPage({super.key, required this.narrow}); - static Route buildRoute(BuildContext context) { + static Route buildRoute({required BuildContext context, required Narrow narrow}) { return MaterialAccountPageRoute(context: context, - builder: (context) => const MessageListPage()); + builder: (context) => MessageListPage(narrow: narrow)); } + final Narrow narrow; + @override Widget build(BuildContext context) { return Scaffold( @@ -173,8 +177,8 @@ class MessageListPage extends StatelessWidget { // The compose box pads the bottom inset. removeBottom: true, - child: const Expanded( - child: MessageList())), + child: Expanded( + child: MessageList(narrow: narrow))), const StreamComposeBox(), ])))); } diff --git a/lib/widgets/compose_box.dart b/lib/widgets/compose_box.dart index 66fe2559ae..f4f5bcb060 100644 --- a/lib/widgets/compose_box.dart +++ b/lib/widgets/compose_box.dart @@ -517,10 +517,12 @@ class _StreamSendButtonState extends State<_StreamSendButton> { } final store = PerAccountStoreWidget.of(context); - store.sendStreamMessage( - topic: widget.topicController.textNormalized(), - content: widget.contentController.textNormalized(), - ); + if (store.connection.realmUrl.origin != 'https://chat.zulip.org') { + throw Exception('This method can currently only be used on https://chat.zulip.org.'); + } + final destination = StreamDestination(7, widget.topicController.textNormalized()); // TODO parametrize; this is `#test here` + final content = widget.contentController.textNormalized(); + store.sendMessage(destination: destination, content: content); widget.contentController.clear(); } diff --git a/lib/widgets/message_list.dart b/lib/widgets/message_list.dart index d7e3e3d06f..2f1fb81f82 100644 --- a/lib/widgets/message_list.dart +++ b/lib/widgets/message_list.dart @@ -13,15 +13,15 @@ import 'sticky_header.dart'; import 'store.dart'; class MessageList extends StatefulWidget { - const MessageList({super.key}); + const MessageList({super.key, required this.narrow}); + + final Narrow narrow; @override State createState() => _MessageListState(); } class _MessageListState extends State { - Narrow get narrow => const AllMessagesNarrow(); // TODO specify in widget - MessageListView? model; @override @@ -44,7 +44,7 @@ class _MessageListState extends State { } void _initModel(PerAccountStore store) { - model = MessageListView.init(store: store, narrow: narrow); + model = MessageListView.init(store: store, narrow: widget.narrow); model!.addListener(_modelChanged); model!.fetch(); } diff --git a/test/api/route/messages_test.dart b/test/api/route/messages_test.dart index d74e693e87..7e571c7275 100644 --- a/test/api/route/messages_test.dart +++ b/test/api/route/messages_test.dart @@ -1,24 +1,52 @@ +import 'dart:convert'; + import 'package:checks/checks.dart'; +import 'package:http/http.dart' as http; import 'package:test/scaffolding.dart'; import 'package:zulip/api/route/messages.dart'; +import '../../stdlib_checks.dart'; import '../fake_api.dart'; import 'route_checks.dart'; void main() { - test('sendMessage accepts fixture realm', () async { - final connection = FakeApiConnection( - realmUrl: Uri.parse('https://chat.zulip.org/')); - connection.prepare(json: SendMessageResult(id: 42).toJson()); - check(sendMessage(connection, content: 'hello', topic: 'world')) - .completes(it()..id.equals(42)); + test('sendMessage to stream', () { + return FakeApiConnection.with_((connection) async { + const streamId = 123; + const topic = 'world'; + const content = 'hello'; + connection.prepare(json: SendMessageResult(id: 42).toJson()); + final result = await sendMessage(connection, + destination: StreamDestination(streamId, topic), content: content); + check(result).id.equals(42); + check(connection.lastRequest).isNotNull().isA() + ..method.equals('POST') + ..url.path.equals('/api/v1/messages') + ..bodyFields.deepEquals({ + 'type': 'stream', + 'to': streamId.toString(), + 'topic': topic, + 'content': content, + }); + }); }); - test('sendMessage rejects unexpected realm', () async { - final connection = FakeApiConnection( - realmUrl: Uri.parse('https://chat.example/')); - connection.prepare(json: SendMessageResult(id: 42).toJson()); - check(() => sendMessage(connection, content: 'hello', topic: 'world')) - .throws(); + test('sendMessage to PM conversation', () { + return FakeApiConnection.with_((connection) async { + const userIds = [23, 34]; + const content = 'hi there'; + connection.prepare(json: SendMessageResult(id: 42).toJson()); + final result = await sendMessage(connection, + destination: PmDestination(userIds: userIds), content: content); + check(result).id.equals(42); + check(connection.lastRequest).isNotNull().isA() + ..method.equals('POST') + ..url.path.equals('/api/v1/messages') + ..bodyFields.deepEquals({ + 'type': 'private', + 'to': jsonEncode(userIds), + 'content': content, + }); + }); }); }