From f0b10c8dcf3eaf6e7dedbdc35e54216c67e996d1 Mon Sep 17 00:00:00 2001 From: Chris Bobbe Date: Thu, 16 Feb 2023 14:22:33 -0800 Subject: [PATCH 1/2] api: Add send-message route --- lib/api/route/messages.dart | 53 +++++++++++++++++++++++++++++++++++ lib/api/route/messages.g.dart | 12 ++++++++ 2 files changed, 65 insertions(+) diff --git a/lib/api/route/messages.dart b/lib/api/route/messages.dart index f5fba0005a..f2a9dec6a9 100644 --- a/lib/api/route/messages.dart +++ b/lib/api/route/messages.dart @@ -46,3 +46,56 @@ class GetMessagesResult { Map toJson() => _$GetMessagesResultToJson(this); } + +// https://zulip.com/api/send-message#parameter-topic +const int kMaxTopicLength = 60; + +// https://zulip.com/api/send-message#parameter-content +const int kMaxMessageLengthCodePoints = 10000; + +/// The topic servers understand to mean "there is no topic". +/// +/// This should match +/// https://github.com/zulip/zulip/blob/6.0/zerver/actions/message_edit.py#L940 +/// or similar logic at the latest `main`. +// This is hardcoded in the server, and therefore untranslated; that's +// zulip/zulip#3639. +const String kNoTopicTopic = '(no topic)'; + +/// https://zulip.com/api/send-message +// TODO currently only handles stream messages; fix +Future sendMessage( + ApiConnection connection, { + required String content, + required String topic, +}) async { + // assert() is less verbose but would have no effect in production, I think: + // https://dart.dev/guides/language/language-tour#assert + if (Uri.parse(connection.auth.realmUrl).origin != 'https://chat.zulip.org') { + throw Exception('This binding can currently only be used on https://chat.zulip.org.'); + } + + final data = await connection.post('messages', { + 'type': RawParameter('stream'), // TODO parametrize + 'to': 7, // TODO parametrize; this is `#test here` + 'topic': RawParameter(topic), + 'content': RawParameter(content), + }); + return SendMessageResult.fromJson(jsonDecode(data)); +} + +@JsonSerializable() +class SendMessageResult { + final int id; + final String? deliver_at; + + SendMessageResult({ + required this.id, + this.deliver_at, + }); + + factory SendMessageResult.fromJson(Map json) => + _$SendMessageResultFromJson(json); + + Map toJson() => _$SendMessageResultToJson(this); +} diff --git a/lib/api/route/messages.g.dart b/lib/api/route/messages.g.dart index b2c6f4f5f3..79b94f74f7 100644 --- a/lib/api/route/messages.g.dart +++ b/lib/api/route/messages.g.dart @@ -27,3 +27,15 @@ Map _$GetMessagesResultToJson(GetMessagesResult instance) => 'history_limited': instance.history_limited, 'messages': instance.messages, }; + +SendMessageResult _$SendMessageResultFromJson(Map json) => + SendMessageResult( + id: json['id'] as int, + deliver_at: json['deliver_at'] as String?, + ); + +Map _$SendMessageResultToJson(SendMessageResult instance) => + { + 'id': instance.id, + 'deliver_at': instance.deliver_at, + }; From cd4ff5e3231475f3a4ff6a81b1f0fdc6c9394aaf Mon Sep 17 00:00:00 2001 From: Chris Bobbe Date: Sun, 8 Jan 2023 11:16:12 -0800 Subject: [PATCH 2/2] compose: Prototype compose box, using Material `TextField` widget --- lib/model/store.dart | 6 + lib/widgets/app.dart | 6 +- lib/widgets/compose_box.dart | 319 ++++++++++++++++++++++++++++++++++ lib/widgets/dialog.dart | 29 ++++ lib/widgets/message_list.dart | 11 ++ 5 files changed, 367 insertions(+), 4 deletions(-) create mode 100644 lib/widgets/compose_box.dart create mode 100644 lib/widgets/dialog.dart diff --git a/lib/model/store.dart b/lib/model/store.dart index c8643a09b9..f31591ec93 100644 --- a/lib/model/store.dart +++ b/lib/model/store.dart @@ -9,6 +9,7 @@ import '../api/model/events.dart'; import '../api/model/initial_snapshot.dart'; import '../api/model/model.dart'; import '../api/route/events.dart'; +import '../api/route/messages.dart'; import '../credential_fixture.dart' as credentials; import 'message_list.dart'; @@ -106,6 +107,11 @@ class PerAccountStore extends ChangeNotifier { } } + Future sendStreamMessage({required String topic, 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); + } } /// A scaffolding hack for while prototyping. diff --git a/lib/widgets/app.dart b/lib/widgets/app.dart index 95f52e2464..a5293b4d6f 100644 --- a/lib/widgets/app.dart +++ b/lib/widgets/app.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; +import 'compose_box.dart'; import 'message_list.dart'; import '../model/store.dart'; @@ -163,9 +164,6 @@ class MessageListPage extends StatelessWidget { child: const Expanded( child: MessageList())), - const SizedBox( - height: 80, - child: Center( - child: Text("(Compose box goes here.)")))])))); + const StreamComposeBox()])))); } } diff --git a/lib/widgets/compose_box.dart b/lib/widgets/compose_box.dart new file mode 100644 index 0000000000..44d28209ed --- /dev/null +++ b/lib/widgets/compose_box.dart @@ -0,0 +1,319 @@ +import 'package:flutter/material.dart'; +import 'dialog.dart'; + +import 'app.dart'; +import '../api/route/messages.dart'; + +enum TopicValidationError { + mandatoryButEmpty, + tooLong; + + String message() { + switch (this) { + case tooLong: + return "Topic length shouldn't be greater than 60 characters."; + case mandatoryButEmpty: + return 'Topics are required in this organization.'; + } + } +} + +class TopicTextEditingController extends TextEditingController { + // TODO: subscribe to this value: + // https://zulip.com/help/require-topics + final mandatory = true; + + String textNormalized() { + String trimmed = text.trim(); + return trimmed.isEmpty ? kNoTopicTopic : trimmed; + } + + List validationErrors() { + final normalized = textNormalized(); + return [ + if (mandatory && normalized == kNoTopicTopic) + TopicValidationError.mandatoryButEmpty, + if (normalized.length > kMaxTopicLength) + TopicValidationError.tooLong, + ]; + } +} + +enum ContentValidationError { + empty, + tooLong; + + // Later: upload in progress; quote-and-reply in progress + + String message() { + switch (this) { + case ContentValidationError.tooLong: + return "Message length shouldn't be greater than 10000 characters."; + case ContentValidationError.empty: + return 'You have nothing to send!'; + } + } +} + +class ContentTextEditingController extends TextEditingController { + String textNormalized() { + return text.trim(); + } + + List validationErrors() { + final normalized = textNormalized(); + return [ + if (normalized.isEmpty) + ContentValidationError.empty, + + // normalized.length is the number of UTF-16 code units, while the server + // API expresses the max in Unicode code points. So this comparison will + // be conservative and may cut the user off shorter than necessary. + if (normalized.length > kMaxMessageLengthCodePoints) + ContentValidationError.tooLong, + ]; + } +} + +/// The content input for StreamComposeBox. +class _StreamContentInput extends StatefulWidget { + const _StreamContentInput({required this.controller, required this.topicController}); + + final ContentTextEditingController controller; + final TopicTextEditingController topicController; + + @override + State<_StreamContentInput> createState() => _StreamContentInputState(); +} + +class _StreamContentInputState extends State<_StreamContentInput> { + late String _topicTextNormalized; + + _topicValueChanged() { + setState(() { + _topicTextNormalized = widget.topicController.textNormalized(); + }); + } + + @override + void initState() { + super.initState(); + _topicTextNormalized = widget.topicController.textNormalized(); + widget.topicController.addListener(_topicValueChanged); + } + + @override + void dispose() { + widget.topicController.removeListener(_topicValueChanged); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + ColorScheme colorScheme = Theme.of(context).colorScheme; + + return ConstrainedBox( + // TODO constrain height adaptively (i.e. not hard-coded 200) + constraints: const BoxConstraints(maxHeight: 200), + + child: TextField( + controller: widget.controller, + style: TextStyle(color: colorScheme.onSurface), + decoration: InputDecoration( + hintText: "Message #test here > $_topicTextNormalized", + ), + maxLines: null, + ), + ); + } +} + + +/// The send button for StreamComposeBox. +class _StreamSendButton extends StatefulWidget { + const _StreamSendButton({required this.topicController, required this.contentController}); + + final TopicTextEditingController topicController; + final ContentTextEditingController contentController; + + @override + State<_StreamSendButton> createState() => _StreamSendButtonState(); +} + +class _StreamSendButtonState extends State<_StreamSendButton> { + late List _topicValidationErrors; + late List _contentValidationErrors; + + _topicValueChanged() { + final oldIsEmpty = _topicValidationErrors.isEmpty; + final newErrors = widget.topicController.validationErrors(); + final newIsEmpty = newErrors.isEmpty; + _topicValidationErrors = newErrors; + if (oldIsEmpty != newIsEmpty) { + setState(() { + // Update disabled/non-disabled state + }); + } + } + + _contentValueChanged() { + final oldIsEmpty = _contentValidationErrors.isEmpty; + final newErrors = widget.contentController.validationErrors(); + final newIsEmpty = newErrors.isEmpty; + _contentValidationErrors = newErrors; + if (oldIsEmpty != newIsEmpty) { + setState(() { + // Update disabled/non-disabled state + }); + } + } + + @override + void initState() { + super.initState(); + _topicValidationErrors = widget.topicController.validationErrors(); + _contentValidationErrors = widget.contentController.validationErrors(); + widget.topicController.addListener(_topicValueChanged); + widget.contentController.addListener(_contentValueChanged); + } + + @override + void dispose() { + widget.topicController.removeListener(_topicValueChanged); + widget.contentController.removeListener(_contentValueChanged); + super.dispose(); + } + + void _showSendFailedDialog(BuildContext context) { + List validationErrorMessages = [ + for (final error in _topicValidationErrors) + error.message(), + for (final error in _contentValidationErrors) + error.message(), + ]; + + return showErrorDialog( + context: context, + title: 'Message not sent', + message: validationErrorMessages.join('\n\n')); + } + + void _handleSendPressed(BuildContext context) { + if (_topicValidationErrors.isNotEmpty || _contentValidationErrors.isNotEmpty) { + _showSendFailedDialog(context); + return; + } + + final store = PerAccountStoreWidget.of(context); + store.sendStreamMessage( + topic: widget.topicController.textNormalized(), + content: widget.contentController.textNormalized(), + ); + + widget.contentController.clear(); + } + + @override + Widget build(BuildContext context) { + ColorScheme colorScheme = Theme.of(context).colorScheme; + + bool disabled = _topicValidationErrors.isNotEmpty || _contentValidationErrors.isNotEmpty; + + // Copy FilledButton defaults (_FilledButtonDefaultsM3.backgroundColor) + final backgroundColor = disabled + ? colorScheme.onSurface.withOpacity(0.12) + : colorScheme.primary; + + // Copy FilledButton defaults (_FilledButtonDefaultsM3.foregroundColor) + final foregroundColor = disabled + ? colorScheme.onSurface.withOpacity(0.38) + : colorScheme.onPrimary; + + return Ink( + decoration: BoxDecoration( + borderRadius: const BorderRadius.all(Radius.circular(8.0)), + color: backgroundColor, + ), + child: IconButton( + // Empirically, match the height of the content input. Ideally, this + // would be dynamic and respond to the actual height of the content + // input. Zeroing the padding lets the constraints take over. + constraints: const BoxConstraints(minWidth: 35, minHeight: 35), + padding: const EdgeInsets.all(0), + + color: foregroundColor, + icon: const Icon(Icons.send), + onPressed: () => _handleSendPressed(context), + ), + ); + } +} + +/// The compose box for writing a stream message. +class StreamComposeBox extends StatefulWidget { + const StreamComposeBox({super.key}); + + @override + State createState() => _StreamComposeBoxState(); +} + +class _StreamComposeBoxState extends State { + final _topicController = TopicTextEditingController(); + final _contentController = ContentTextEditingController(); + + @override + void dispose() { + _topicController.dispose(); + _contentController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + ThemeData themeData = Theme.of(context); + ColorScheme colorScheme = themeData.colorScheme; + + final inputThemeData = themeData.copyWith( + inputDecorationTheme: InputDecorationTheme( + // Both [contentPadding] and [isDense] combine to make the layout compact. + isDense: true, + contentPadding: const EdgeInsets.symmetric( + horizontal: 12.0, vertical: 8.0), + + border: const OutlineInputBorder( + borderRadius: BorderRadius.all(Radius.circular(4.0)), + borderSide: BorderSide.none, + ), + + filled: true, + fillColor: colorScheme.surface, + ), + ); + + final topicInput = TextField( + controller: _topicController, + style: TextStyle(color: colorScheme.onSurface), + decoration: const InputDecoration(hintText: 'Topic'), + ); + + return Material( + color: colorScheme.surfaceVariant, + child: SafeArea( + minimum: const EdgeInsets.fromLTRB(8, 0, 8, 8), + child: Padding( + padding: const EdgeInsets.only(top: 8.0), + child: Row(crossAxisAlignment: CrossAxisAlignment.end, children: [ + Expanded( + child: Theme( + data: inputThemeData, + child: Column( + children: [ + topicInput, + const SizedBox(height: 8), + _StreamContentInput(topicController: _topicController, controller: _contentController), + ]))), + const SizedBox(width: 8), + _StreamSendButton(topicController: _topicController, contentController: _contentController), + ])))); + } +} diff --git a/lib/widgets/dialog.dart b/lib/widgets/dialog.dart new file mode 100644 index 0000000000..bc42106b52 --- /dev/null +++ b/lib/widgets/dialog.dart @@ -0,0 +1,29 @@ +import 'package:flutter/material.dart'; + +Widget _dialogActionText(String text) { + return Text( + text, + + // As suggested by + // https://api.flutter.dev/flutter/material/AlertDialog/actions.html : + // > It is recommended to set the Text.textAlign to TextAlign.end + // > for the Text within the TextButton, so that buttons whose + // > labels wrap to an extra line align with the overall + // > OverflowBar's alignment within the dialog. + textAlign: TextAlign.end, + ); +} + +// TODO(i18n): title, message, and action-button text +void showErrorDialog({required BuildContext context, required String title, String? message}) { + showDialog( + context: context, + builder: (BuildContext context) => AlertDialog( + title: Text(title), + content: message != null ? SingleChildScrollView(child: Text(message)) : null, + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: _dialogActionText('OK')), + ])); +} diff --git a/lib/widgets/message_list.dart b/lib/widgets/message_list.dart index 10686834c1..44dfeddb5e 100644 --- a/lib/widgets/message_list.dart +++ b/lib/widgets/message_list.dart @@ -1,3 +1,4 @@ +import 'dart:io' show Platform; import 'package:flutter/material.dart'; import 'package:intl/intl.dart'; @@ -81,6 +82,16 @@ class _MessageListState extends State { final length = model!.messages.length; assert(model!.contents.length == length); return StickyHeaderListView.builder( + // TODO: Offer `ScrollViewKeyboardDismissBehavior.interactive` (or + // similar) if that is ever offered: + // https://github.com/flutter/flutter/issues/57609#issuecomment-1355340849 + keyboardDismissBehavior: Platform.isIOS + // This seems to offer the only built-in way to close the keyboard + // on iOS. It's not ideal; see TODO above. + ? ScrollViewKeyboardDismissBehavior.onDrag + // The Android keyboard seems to have a built-in close button. + : ScrollViewKeyboardDismissBehavior.manual, + itemCount: length, // Setting reverse: true means the scroll starts at the bottom. // Flipping the indexes (in itemBuilder) means the start/bottom