diff --git a/assets/icons/ZulipIcons.ttf b/assets/icons/ZulipIcons.ttf
index 6ca113431c..04e2b86e9c 100644
Binary files a/assets/icons/ZulipIcons.ttf and b/assets/icons/ZulipIcons.ttf differ
diff --git a/assets/icons/star_filled.svg b/assets/icons/star_filled.svg
new file mode 100644
index 0000000000..de3a73cf10
--- /dev/null
+++ b/assets/icons/star_filled.svg
@@ -0,0 +1,5 @@
+
diff --git a/assets/l10n/app_en.arb b/assets/l10n/app_en.arb
index 574aded4f9..63e8249b72 100644
--- a/assets/l10n/app_en.arb
+++ b/assets/l10n/app_en.arb
@@ -55,6 +55,14 @@
"@actionSheetOptionQuoteAndReply": {
"description": "Label for Quote and reply button on action sheet."
},
+ "actionSheetOptionStarMessage": "Star message",
+ "@actionSheetOptionStarMessage": {
+ "description": "Label for star button on action sheet."
+ },
+ "actionSheetOptionUnstarMessage": "Unstar message",
+ "@actionSheetOptionUnstarMessage": {
+ "description": "Label for unstar button on action sheet."
+ },
"errorCouldNotFetchMessageSource": "Could not fetch message source",
"@errorCouldNotFetchMessageSource": {
"description": "Error message when the source of a message could not be fetched."
@@ -128,6 +136,14 @@
"@errorSharingFailed": {
"description": "Error message when sharing a message failed."
},
+ "errorStarMessageFailedTitle": "Failed to star message",
+ "@errorStarMessageFailedTitle": {
+ "description": "Error title when starring a message failed."
+ },
+ "errorUnstarMessageFailedTitle": "Failed to unstar message",
+ "@errorUnstarMessageFailedTitle": {
+ "description": "Error title when unstarring a message failed."
+ },
"successLinkCopied": "Link copied",
"@successLinkCopied": {
"description": "Success message after copy link action completed."
diff --git a/lib/widgets/action_sheet.dart b/lib/widgets/action_sheet.dart
index a35cb08f3b..7ca86d3779 100644
--- a/lib/widgets/action_sheet.dart
+++ b/lib/widgets/action_sheet.dart
@@ -10,6 +10,7 @@ import 'clipboard.dart';
import 'compose_box.dart';
import 'dialog.dart';
import 'draggable_scrollable_modal_bottom_sheet.dart';
+import 'icons.dart';
import 'message_list.dart';
import 'store.dart';
@@ -38,6 +39,7 @@ void showMessageActionSheet({required BuildContext context, required Message mes
builder: (BuildContext _) {
return Column(children: [
if (!hasThumbsUpReactionVote) AddThumbsUpButton(message: message, messageListContext: context),
+ StarButton(message: message, messageListContext: context),
ShareButton(message: message, messageListContext: context),
if (isComposeBoxOffered) QuoteAndReplyButton(
message: message,
@@ -115,6 +117,54 @@ class AddThumbsUpButton extends MessageActionSheetMenuItemButton {
};
}
+class StarButton extends MessageActionSheetMenuItemButton {
+ StarButton({
+ super.key,
+ required super.message,
+ required super.messageListContext,
+ });
+
+ @override get icon => ZulipIcons.star_filled;
+
+ @override
+ String label(ZulipLocalizations zulipLocalizations) {
+ return message.flags.contains(MessageFlag.starred)
+ ? zulipLocalizations.actionSheetOptionUnstarMessage
+ : zulipLocalizations.actionSheetOptionStarMessage;
+ }
+
+ @override get onPressed => (BuildContext context) async {
+ Navigator.of(context).pop();
+ final zulipLocalizations = ZulipLocalizations.of(messageListContext);
+ final op = message.flags.contains(MessageFlag.starred)
+ ? UpdateMessageFlagsOp.remove
+ : UpdateMessageFlagsOp.add;
+
+ try {
+ final connection = PerAccountStoreWidget.of(messageListContext).connection;
+ await updateMessageFlags(connection, messages: [message.id],
+ op: op, flag: MessageFlag.starred);
+ } catch (e) {
+ if (!messageListContext.mounted) return;
+
+ String? errorMessage;
+ switch (e) {
+ case ZulipApiException():
+ errorMessage = e.message;
+ // TODO specific messages for common errors, like network errors
+ // (support with reusable code)
+ default:
+ }
+
+ await showErrorDialog(context: messageListContext,
+ title: switch(op) {
+ UpdateMessageFlagsOp.remove => zulipLocalizations.errorUnstarMessageFailedTitle,
+ UpdateMessageFlagsOp.add => zulipLocalizations.errorStarMessageFailedTitle,
+ }, message: errorMessage);
+ }
+ };
+}
+
class ShareButton extends MessageActionSheetMenuItemButton {
ShareButton({
super.key,
diff --git a/lib/widgets/icons.dart b/lib/widgets/icons.dart
index fe6ddf30cf..461be1f107 100644
--- a/lib/widgets/icons.dart
+++ b/lib/widgets/icons.dart
@@ -57,14 +57,17 @@ abstract final class ZulipIcons {
/// The Zulip custom icon "read_receipts".
static const IconData read_receipts = IconData(0xf10b, fontFamily: "Zulip Icons");
+ /// The Zulip custom icon "star_filled".
+ static const IconData star_filled = IconData(0xf10c, fontFamily: "Zulip Icons");
+
/// The Zulip custom icon "topic".
- static const IconData topic = IconData(0xf10c, fontFamily: "Zulip Icons");
+ static const IconData topic = IconData(0xf10d, fontFamily: "Zulip Icons");
/// The Zulip custom icon "unmute".
- static const IconData unmute = IconData(0xf10d, fontFamily: "Zulip Icons");
+ static const IconData unmute = IconData(0xf10e, fontFamily: "Zulip Icons");
/// The Zulip custom icon "user".
- static const IconData user = IconData(0xf10e, fontFamily: "Zulip Icons");
+ static const IconData user = IconData(0xf10f, fontFamily: "Zulip Icons");
// END GENERATED ICON DATA
}
diff --git a/lib/widgets/message_list.dart b/lib/widgets/message_list.dart
index d9e3dbf0e9..2e01d1ff45 100644
--- a/lib/widgets/message_list.dart
+++ b/lib/widgets/message_list.dart
@@ -849,6 +849,8 @@ class MessageWithPossibleSender extends StatelessWidget {
final MessageListMessageItem item;
+ static final _starColor = const HSLColor.fromAHSL(0.5, 47, 1, 0.41).toColor();
+
@override
Widget build(BuildContext context) {
final message = item.message;
@@ -913,7 +915,14 @@ class MessageWithPossibleSender extends StatelessWidget {
if ((message.reactions?.total ?? 0) > 0)
ReactionChipsList(messageId: message.id, reactions: message.reactions!)
])),
- const SizedBox(width: 16),
+ SizedBox(width: 16,
+ child: message.flags.contains(MessageFlag.starred)
+ // TODO(#157): fix how star marker aligns with message content
+ // Design from Figma at:
+ // https://www.figma.com/file/1JTNtYo9memgW7vV6d0ygq/Zulip-Mobile?node-id=813%3A28817&mode=dev .
+ ? Padding(padding: const EdgeInsets.only(top: 4),
+ child: Icon(ZulipIcons.star_filled, size: 16, color: _starColor))
+ : null),
]),
])));
}
diff --git a/test/widgets/action_sheet_test.dart b/test/widgets/action_sheet_test.dart
index e4b1df53e9..91bca5b091 100644
--- a/test/widgets/action_sheet_test.dart
+++ b/test/widgets/action_sheet_test.dart
@@ -1,3 +1,5 @@
+import 'dart:convert';
+
import 'package:checks/checks.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
@@ -7,10 +9,12 @@ import 'package:http/http.dart' as http;
import 'package:zulip/api/model/model.dart';
import 'package:zulip/api/route/messages.dart';
import 'package:zulip/model/compose.dart';
+import 'package:zulip/model/localizations.dart';
import 'package:zulip/model/narrow.dart';
import 'package:zulip/model/store.dart';
import 'package:zulip/widgets/compose_box.dart';
import 'package:zulip/widgets/content.dart';
+import 'package:zulip/widgets/icons.dart';
import 'package:zulip/widgets/message_list.dart';
import 'package:zulip/widgets/store.dart';
import 'package:share_plus_platform_interface/method_channel/method_channel_share.dart';
@@ -142,6 +146,102 @@ void main() {
});
});
+ group('StarButton', () {
+ Future tapButton(WidgetTester tester) async {
+ // Starred messages include the same icon so we need to
+ // match only by descendants of [BottomSheet].
+ await tester.ensureVisible(find.descendant(
+ of: find.byType(BottomSheet),
+ matching: find.byIcon(ZulipIcons.star_filled, skipOffstage: false)));
+ await tester.tap(find.descendant(
+ of: find.byType(BottomSheet),
+ matching: find.byIcon(ZulipIcons.star_filled)));
+ await tester.pump(); // [MenuItemButton.onPressed] called in a post-frame callback: flutter/flutter@e4a39fa2e
+ }
+
+ testWidgets('star success', (WidgetTester tester) async {
+ final message = eg.streamMessage(flags: []);
+ await setupToMessageActionSheet(tester, message: message, narrow: TopicNarrow.ofMessage(message));
+ final store = await testBinding.globalStore.perAccount(eg.selfAccount.id);
+
+ final connection = store.connection as FakeApiConnection;
+ connection.prepare(json: {});
+ await tapButton(tester);
+ await tester.pump(Duration.zero);
+
+ check(connection.lastRequest).isA()
+ ..method.equals('POST')
+ ..url.path.equals('/api/v1/messages/flags')
+ ..bodyFields.deepEquals({
+ 'messages': jsonEncode([message.id]),
+ 'op': 'add',
+ 'flag': 'starred',
+ });
+ });
+
+ testWidgets('unstar success', (WidgetTester tester) async {
+ final message = eg.streamMessage(flags: [MessageFlag.starred]);
+ await setupToMessageActionSheet(tester, message: message, narrow: TopicNarrow.ofMessage(message));
+ final store = await testBinding.globalStore.perAccount(eg.selfAccount.id);
+
+ final connection = store.connection as FakeApiConnection;
+ connection.prepare(json: {});
+ await tapButton(tester);
+ await tester.pump(Duration.zero);
+
+ check(connection.lastRequest).isA()
+ ..method.equals('POST')
+ ..url.path.equals('/api/v1/messages/flags')
+ ..bodyFields.deepEquals({
+ 'messages': jsonEncode([message.id]),
+ 'op': 'remove',
+ 'flag': 'starred',
+ });
+ });
+
+ testWidgets('star request has an error', (WidgetTester tester) async {
+ final message = eg.streamMessage(flags: []);
+ await setupToMessageActionSheet(tester, message: message, narrow: TopicNarrow.ofMessage(message));
+ final store = await testBinding.globalStore.perAccount(eg.selfAccount.id);
+ final zulipLocalizations = GlobalLocalizations.zulipLocalizations;
+
+ final connection = store.connection as FakeApiConnection;
+
+ connection.prepare(httpStatus: 400, json: {
+ 'code': 'BAD_REQUEST',
+ 'msg': 'Invalid message(s)',
+ 'result': 'error',
+ });
+ await tapButton(tester);
+ await tester.pump(Duration.zero); // error arrives; error dialog shows
+
+ await tester.tap(find.byWidget(checkErrorDialog(tester,
+ expectedTitle: zulipLocalizations.errorStarMessageFailedTitle,
+ expectedMessage: 'Invalid message(s)')));
+ });
+
+ testWidgets('unstar request has an error', (WidgetTester tester) async {
+ final message = eg.streamMessage(flags: [MessageFlag.starred]);
+ await setupToMessageActionSheet(tester, message: message, narrow: TopicNarrow.ofMessage(message));
+ final store = await testBinding.globalStore.perAccount(eg.selfAccount.id);
+ final zulipLocalizations = GlobalLocalizations.zulipLocalizations;
+
+ final connection = store.connection as FakeApiConnection;
+
+ connection.prepare(httpStatus: 400, json: {
+ 'code': 'BAD_REQUEST',
+ 'msg': 'Invalid message(s)',
+ 'result': 'error',
+ });
+ await tapButton(tester);
+ await tester.pump(Duration.zero); // error arrives; error dialog shows
+
+ await tester.tap(find.byWidget(checkErrorDialog(tester,
+ expectedTitle: zulipLocalizations.errorUnstarMessageFailedTitle,
+ expectedMessage: 'Invalid message(s)')));
+ });
+ });
+
group('ShareButton', () {
// Tests should call this.
MockSharePlus setupMockSharePlus() {
diff --git a/test/widgets/message_list_test.dart b/test/widgets/message_list_test.dart
index f116ac5fcd..6d2ecda5e6 100644
--- a/test/widgets/message_list_test.dart
+++ b/test/widgets/message_list_test.dart
@@ -455,6 +455,20 @@ void main() {
});
});
+ group('Starred messages', () {
+ testWidgets('unstarred message', (WidgetTester tester) async {
+ final message = eg.streamMessage(flags: []);
+ await setupMessageListPage(tester, messages: [message]);
+ check(find.byIcon(ZulipIcons.star_filled).evaluate()).isEmpty();
+ });
+
+ testWidgets('starred message', (WidgetTester tester) async {
+ final message = eg.streamMessage(flags: [MessageFlag.starred]);
+ await setupMessageListPage(tester, messages: [message]);
+ check(find.byIcon(ZulipIcons.star_filled).evaluate()).length.equals(1);
+ });
+ });
+
group('_UnreadMarker animations', () {
// TODO: Improve animation state testing so it is less tied to
// implementation details and more focused on output, see: