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