Skip to content

api: Get and send messages from arbitrary places, not hard-coded #138

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

Merged
merged 11 commits into from
May 31, 2023
60 changes: 60 additions & 0 deletions lib/api/model/narrow.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
typedef ApiNarrow = List<ApiNarrowElement>;

/// An element in the list representing a narrow in the Zulip API.
///
/// Docs: <https://zulip.com/api/construct-narrow>
///
/// 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<String, dynamic> 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<String, dynamic> 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<String, dynamic> 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<int> operand;

ApiNarrowPmWith(this.operand, {super.negated});

factory ApiNarrowPmWith.fromJson(Map<String, dynamic> json) => ApiNarrowPmWith(
(json['operand'] as List<dynamic>).map((e) => e as int).toList(),
negated: json['negated'] as bool? ?? false,
);
}
90 changes: 77 additions & 13 deletions lib/api/route/messages.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<GetMessagesResult> 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;
Expand Down Expand Up @@ -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<SendMessageResult> 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<int> userIds;
}

@JsonSerializable(fieldRename: FieldRename.snake)
class SendMessageResult {
final int id;
Expand Down
2 changes: 2 additions & 0 deletions lib/model/message_list.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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,
);
Expand Down
64 changes: 59 additions & 5 deletions lib/model/narrow.dart
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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;
Expand All @@ -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
6 changes: 4 additions & 2 deletions lib/model/store.dart
Original file line number Diff line number Diff line change
Expand Up @@ -248,13 +248,15 @@ class PerAccountStore extends ChangeNotifier {
}
}

Future<void> sendStreamMessage({required String topic, required String content}) {
Future<void> 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
Expand Down
16 changes: 10 additions & 6 deletions lib/widgets/app.dart
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import 'package:flutter/material.dart';

import '../model/narrow.dart';
import 'about_zulip.dart';
import 'compose_box.dart';
import 'login.dart';
Expand Down Expand Up @@ -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<void> buildRoute(BuildContext context) {
static Route<void> 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(
Expand All @@ -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(),
]))));
}
Expand Down
10 changes: 6 additions & 4 deletions lib/widgets/compose_box.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
Expand Down
8 changes: 4 additions & 4 deletions lib/widgets/message_list.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<StatefulWidget> createState() => _MessageListState();
}

class _MessageListState extends State<MessageList> {
Narrow get narrow => const AllMessagesNarrow(); // TODO specify in widget

MessageListView? model;

@override
Expand All @@ -44,7 +44,7 @@ class _MessageListState extends State<MessageList> {
}

void _initModel(PerAccountStore store) {
model = MessageListView.init(store: store, narrow: narrow);
model = MessageListView.init(store: store, narrow: widget.narrow);
model!.addListener(_modelChanged);
model!.fetch();
}
Expand Down
Loading