Skip to content

Commit

Permalink
action_sheet: Support reacting with popular emojis
Browse files Browse the repository at this point in the history
  • Loading branch information
rajveermalviya committed Dec 4, 2024
1 parent 7dba11f commit dc1bb71
Show file tree
Hide file tree
Showing 3 changed files with 169 additions and 57 deletions.
85 changes: 62 additions & 23 deletions lib/widgets/action_sheet.dart
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,15 @@ import '../api/exception.dart';
import '../api/model/model.dart';
import '../api/route/messages.dart';
import '../generated/l10n/zulip_localizations.dart';
import '../model/emoji.dart';
import '../model/internal_link.dart';
import '../model/narrow.dart';
import 'actions.dart';
import 'clipboard.dart';
import 'color.dart';
import 'compose_box.dart';
import 'dialog.dart';
import 'emoji.dart';
import 'icons.dart';
import 'inset_shadow.dart';
import 'message_list.dart';
Expand All @@ -41,16 +43,8 @@ void showMessageActionSheet({required BuildContext context, required Message mes
final markAsUnreadSupported = store.connection.zulipFeatureLevel! >= 155; // TODO(server-6)
final showMarkAsUnreadButton = markAsUnreadSupported && isMessageRead;

final hasThumbsUpReactionVote = message.reactions
?.aggregated.any((reactionWithVotes) =>
reactionWithVotes.reactionType == ReactionType.unicodeEmoji
&& reactionWithVotes.emojiCode == '1f44d'
&& reactionWithVotes.userIds.contains(store.selfUserId))
?? false;

final optionButtons = [
if (!hasThumbsUpReactionVote)
AddThumbsUpButton(message: message, pageContext: context),
ReactionButtons(message: message, pageContext: context),
StarButton(message: message, pageContext: context),
if (isComposeBoxOffered)
QuoteAndReplyButton(message: message, pageContext: context),
Expand Down Expand Up @@ -182,27 +176,30 @@ class MessageActionSheetCancelButton extends StatelessWidget {
}
}

// This button is very temporary, to complete #125 before we have a way to
// choose an arbitrary reaction (#388). So, skipping i18n.
class AddThumbsUpButton extends MessageActionSheetMenuItemButton {
AddThumbsUpButton({super.key, required super.message, required super.pageContext});
class ReactionButtons extends StatelessWidget {
const ReactionButtons({
super.key,
required this.message,
required this.pageContext,
});

@override IconData get icon => ZulipIcons.smile;
final Message message;

@override
String label(ZulipLocalizations zulipLocalizations) {
return 'React with 👍'; // TODO(i18n) skip translation for now
}
/// A context within the [MessageListPage] this action sheet was
/// triggered from.
final BuildContext pageContext;

@override void onPressed() async {
void _onPressed(UnicodeEmojiDisplay emoji, bool selfVoted) async {
String? errorMessage;
try {
await addReaction(PerAccountStoreWidget.of(pageContext).connection,
await (selfVoted ? removeReaction : addReaction).call(
PerAccountStoreWidget.of(pageContext).connection,
messageId: message.id,
reactionType: ReactionType.unicodeEmoji,
emojiCode: '1f44d',
emojiName: '+1',
emojiCode: emoji.emojiCode,
emojiName: emoji.emojiName,
);
if (pageContext.mounted) Navigator.pop(pageContext);
} catch (e) {
if (!pageContext.mounted) return;

Expand All @@ -215,9 +212,51 @@ class AddThumbsUpButton extends MessageActionSheetMenuItemButton {
}

showErrorDialog(context: pageContext,
title: 'Adding reaction failed', message: errorMessage);
title: '${selfVoted ? 'Removing' : 'Adding'} reaction failed',
message: errorMessage);
}
}

@override
Widget build(BuildContext context) {
final store = PerAccountStoreWidget.of(pageContext);
final designVariables = DesignVariables.of(context);

bool hasSelfVote(UnicodeEmojiDisplay emoji) {
return message.reactions?.aggregated.any((reactionWithVotes) {
return reactionWithVotes.reactionType == ReactionType.unicodeEmoji
&& tryParseEmojiCodeToUnicode(reactionWithVotes.emojiCode) == emoji.emojiUnicode
&& reactionWithVotes.userIds.contains(store.selfUserId);
}) ?? false;
}

return Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(color: designVariables.contextMenuItemBg.withFadedAlpha(0.12)),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: List.unmodifiable(popularUnicodeEmojis.map((emoji) {
final selfVoted = hasSelfVote(emoji);
return IconButton(
onPressed: () => _onPressed(emoji, selfVoted),
isSelected: selfVoted,
style: IconButton.styleFrom(
padding: EdgeInsets.zero,
splashFactory: NoSplash.splashFactory,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(3.5)),
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
visualDensity: VisualDensity.compact,
).copyWith(backgroundColor: WidgetStateColor.resolveWith((states) =>
states.any((e) => e == WidgetState.pressed || e == WidgetState.selected)
? designVariables.contextMenuItemBg.withFadedAlpha(0.20)
: Colors.transparent)),
icon: UnicodeEmojiWidget(
emojiDisplay: emoji,
notoColorEmojiTextSize: 24,
size: 24));
})))
);
}
}

class StarButton extends MessageActionSheetMenuItemButton {
Expand Down
4 changes: 4 additions & 0 deletions test/flutter_checks.dart
Original file line number Diff line number Diff line change
Expand Up @@ -158,3 +158,7 @@ extension TableRowChecks on Subject<TableRow> {
extension TableChecks on Subject<Table> {
Subject<List<TableRow>> get children => has((x) => x.children, 'children');
}

extension IconButtonChecks on Subject<IconButton> {
Subject<bool?> get isSelected => has((x) => x.isSelected, 'isSelected');
}
137 changes: 103 additions & 34 deletions test/widgets/action_sheet_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,16 @@ import 'package:zulip/api/route/channels.dart';
import 'package:zulip/api/route/messages.dart';
import 'package:zulip/model/binding.dart';
import 'package:zulip/model/compose.dart';
import 'package:zulip/model/emoji.dart';
import 'package:zulip/model/internal_link.dart';
import 'package:zulip/model/localizations.dart';
import 'package:zulip/model/narrow.dart';
import 'package:zulip/model/store.dart';
import 'package:zulip/model/typing_status.dart';
import 'package:zulip/widgets/action_sheet.dart';
import 'package:zulip/widgets/compose_box.dart';
import 'package:zulip/widgets/content.dart';
import 'package:zulip/widgets/emoji.dart';
import 'package:zulip/widgets/icons.dart';
import 'package:zulip/widgets/message_list.dart';
import 'package:share_plus_platform_interface/method_channel/method_channel_share.dart';
Expand All @@ -26,6 +29,7 @@ import '../api/fake_api.dart';
import '../example_data.dart' as eg;
import '../flutter_checks.dart';
import '../model/binding.dart';
import '../model/emoji_test.dart';
import '../model/test_store.dart';
import '../stdlib_checks.dart';
import '../test_clipboard.dart';
Expand Down Expand Up @@ -99,46 +103,107 @@ void main() {
connection.prepare(httpStatus: 400, json: fakeResponseJson);
}

group('AddThumbsUpButton', () {
Future<void> tapButton(WidgetTester tester) async {
await tester.ensureVisible(find.byIcon(ZulipIcons.smile, skipOffstage: false));
await tester.tap(find.byIcon(ZulipIcons.smile));
await tester.pump(); // [MenuItemButton.onPressed] called in a post-frame callback: flutter/flutter@e4a39fa2e
}

testWidgets('success', (tester) async {
final message = eg.streamMessage();
await setupToMessageActionSheet(tester, message: message, narrow: TopicNarrow.ofMessage(message));
group('ReactionButtons', () {
group('popular emoji reactions;', () {
testWidgets('ensure all are shown', (tester) async {
final message = eg.streamMessage();
await setupToMessageActionSheet(tester, message: message, narrow: TopicNarrow.ofMessage(message));

connection.prepare(json: {});
await tapButton(tester);
await tester.pump(Duration.zero);
check(popularUnicodeEmojis).length.equals(6);

// Ensure there are only 6 buttons.
final buttons = tester.widgetList<IconButton>(find.descendant(
of: find.byType(ReactionButtons) ,
matching: find.byType(IconButton)));
check(buttons).length.equals(6);

// Ensure all are unicode emoji buttons.
final emojis = tester.widgetList<UnicodeEmojiWidget>(find.descendant(
of: find.ancestor(
of: find.byType(IconButton),
matching: find.byType(ReactionButtons)),
matching: find.byType(UnicodeEmojiWidget)));
check(emojis).length.equals(6);
check(emojis).deepEquals(popularUnicodeEmojis.map<Condition<Object?>>((emoji) {
return (it) => it.isA<UnicodeEmojiWidget>()
..emojiDisplay.which((it) => it
..emojiName.equals(emoji.emojiName)
..emojiUnicode.equals(emoji.emojiUnicode)
..emojiCode.equals(emoji.emojiCode));
}));
});

check(connection.lastRequest).isA<http.Request>()
..method.equals('POST')
..url.path.equals('/api/v1/messages/${message.id}/reactions')
..bodyFields.deepEquals({
'reaction_type': 'unicode_emoji',
'emoji_code': '1f44d',
'emoji_name': '+1',
});
});
for (final popularEmoji in popularUnicodeEmojis) {
Future<void> tapButton(WidgetTester tester, {required bool isSelected}) async {
final finder = find.ancestor(
of: find.text(popularEmoji.emojiUnicode),
matching: find.byType(IconButton));

check(tester.widget<IconButton>(finder))
.isSelected.equals(isSelected);
await tester.tap(finder);
}

testWidgets('${popularEmoji.emojiName} adding success', (tester) async {
final message = eg.streamMessage();
await setupToMessageActionSheet(tester, message: message, narrow: TopicNarrow.ofMessage(message));

connection.prepare(json: {});
await tapButton(tester, isSelected: false);
await tester.pump(Duration.zero);

check(connection.lastRequest).isA<http.Request>()
..method.equals('POST')
..url.path.equals('/api/v1/messages/${message.id}/reactions')
..bodyFields.deepEquals({
'reaction_type': 'unicode_emoji',
'emoji_code': popularEmoji.emojiCode,
'emoji_name': popularEmoji.emojiName,
});
});

testWidgets('request has an error', (tester) async {
final message = eg.streamMessage();
await setupToMessageActionSheet(tester, message: message, narrow: TopicNarrow.ofMessage(message));
testWidgets('${popularEmoji.emojiName} removing success', (tester) async {
final message = eg.streamMessage(
reactions: [Reaction(
emojiName: popularEmoji.emojiName,
emojiCode: popularEmoji.emojiCode,
reactionType: ReactionType.unicodeEmoji,
userId: eg.selfAccount.userId)]
);
await setupToMessageActionSheet(tester, message: message, narrow: TopicNarrow.ofMessage(message));

connection.prepare(json: {});
await tapButton(tester, isSelected: true);
await tester.pump(Duration.zero);

check(connection.lastRequest).isA<http.Request>()
..method.equals('DELETE')
..url.path.equals('/api/v1/messages/${message.id}/reactions')
..bodyFields.deepEquals({
'reaction_type': 'unicode_emoji',
'emoji_code': popularEmoji.emojiCode,
'emoji_name': popularEmoji.emojiName,
});
});

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
testWidgets('${popularEmoji.emojiName} request has an error', (tester) async {
final message = eg.streamMessage();
await setupToMessageActionSheet(tester, message: message, narrow: TopicNarrow.ofMessage(message));

await tester.tap(find.byWidget(checkErrorDialog(tester,
expectedTitle: 'Adding reaction failed',
expectedMessage: 'Invalid message(s)')));
connection.prepare(httpStatus: 400, json: {
'code': 'BAD_REQUEST',
'msg': 'Invalid message(s)',
'result': 'error',
});
await tapButton(tester, isSelected: false);
await tester.pump(Duration.zero); // error arrives; error dialog shows

await tester.tap(find.byWidget(checkErrorDialog(tester,
expectedTitle: 'Adding reaction failed',
expectedMessage: 'Invalid message(s)')));
});
}
});
});

Expand Down Expand Up @@ -700,3 +765,7 @@ void main() {
});
});
}

extension UnicodeEmojiWidgetChecks on Subject<UnicodeEmojiWidget> {
Subject<UnicodeEmojiDisplay> get emojiDisplay => has((x) => x.emojiDisplay, 'emojiDisplay');
}

0 comments on commit dc1bb71

Please sign in to comment.