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 10, 2024
1 parent d50e048 commit 5cf87f8
Show file tree
Hide file tree
Showing 9 changed files with 268 additions and 72 deletions.
8 changes: 8 additions & 0 deletions assets/l10n/app_en.arb
Original file line number Diff line number Diff line change
Expand Up @@ -603,5 +603,13 @@
"errorNotificationOpenAccountMissing": "The account associated with this notification no longer exists.",
"@errorNotificationOpenAccountMissing": {
"description": "Error message when the account associated with the notification is not found"
},
"errorReactionAddingFailedTitle": "Adding reaction failed",
"@errorReactionAddingFailedTitle": {
"description": "Error title when adding a message reaction fails"
},
"errorReactionRemovingFailedTitle": "Removing reaction failed",
"@errorReactionRemovingFailedTitle": {
"description": "Error title when removing a message reaction fails"
}
}
12 changes: 12 additions & 0 deletions lib/generated/l10n/zulip_localizations.dart
Original file line number Diff line number Diff line change
Expand Up @@ -894,6 +894,18 @@ abstract class ZulipLocalizations {
/// In en, this message translates to:
/// **'The account associated with this notification no longer exists.'**
String get errorNotificationOpenAccountMissing;

/// Error title when adding a message reaction fails
///
/// In en, this message translates to:
/// **'Adding reaction failed'**
String get errorReactionAddingFailedTitle;

/// Error title when removing a message reaction fails
///
/// In en, this message translates to:
/// **'Removing reaction failed'**
String get errorReactionRemovingFailedTitle;
}

class _ZulipLocalizationsDelegate extends LocalizationsDelegate<ZulipLocalizations> {
Expand Down
6 changes: 6 additions & 0 deletions lib/generated/l10n/zulip_localizations_ar.dart
Original file line number Diff line number Diff line change
Expand Up @@ -478,4 +478,10 @@ class ZulipLocalizationsAr extends ZulipLocalizations {

@override
String get errorNotificationOpenAccountMissing => 'The account associated with this notification no longer exists.';

@override
String get errorReactionAddingFailedTitle => 'Adding reaction failed';

@override
String get errorReactionRemovingFailedTitle => 'Removing reaction failed';
}
6 changes: 6 additions & 0 deletions lib/generated/l10n/zulip_localizations_en.dart
Original file line number Diff line number Diff line change
Expand Up @@ -478,4 +478,10 @@ class ZulipLocalizationsEn extends ZulipLocalizations {

@override
String get errorNotificationOpenAccountMissing => 'The account associated with this notification no longer exists.';

@override
String get errorReactionAddingFailedTitle => 'Adding reaction failed';

@override
String get errorReactionRemovingFailedTitle => 'Removing reaction failed';
}
6 changes: 6 additions & 0 deletions lib/generated/l10n/zulip_localizations_ja.dart
Original file line number Diff line number Diff line change
Expand Up @@ -478,4 +478,10 @@ class ZulipLocalizationsJa extends ZulipLocalizations {

@override
String get errorNotificationOpenAccountMissing => 'The account associated with this notification no longer exists.';

@override
String get errorReactionAddingFailedTitle => 'Adding reaction failed';

@override
String get errorReactionRemovingFailedTitle => 'Removing reaction failed';
}
126 changes: 88 additions & 38 deletions lib/widgets/action_sheet.dart
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import 'dart:async';

import 'package:collection/collection.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
Expand All @@ -9,13 +10,16 @@ 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 'emoji_reaction.dart';
import 'icons.dart';
import 'inset_shadow.dart';
import 'message_list.dart';
Expand All @@ -25,7 +29,7 @@ import 'theme.dart';

void _showActionSheet(
BuildContext context, {
required List<ActionSheetMenuItemButton> optionButtons,
required List<Widget> optionButtons,
}) {
showModalBottomSheet<void>(
context: context,
Expand Down Expand Up @@ -161,16 +165,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 All @@ -194,41 +190,95 @@ abstract class MessageActionSheetMenuItemButton extends ActionSheetMenuItemButto
final Message message;
}

// 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;

void _handleTapReaction({
required EmojiCandidate emoji,
required bool isSelfVoted,
}) {
// Dismiss the enclosing action sheet immediately,
// for swift UI feedback that the user's selection was received.
Navigator.pop(pageContext);

final zulipLocalizations = ZulipLocalizations.of(pageContext);
doAddOrRemoveReaction(
context: pageContext,
doRemoveReaction: isSelfVoted,
messageId: message.id,
emoji: emoji,
errorDialogTitle: isSelfVoted
? zulipLocalizations.errorReactionRemovingFailedTitle
: zulipLocalizations.errorReactionAddingFailedTitle);
}

@override void onPressed() async {
String? errorMessage;
try {
await addReaction(PerAccountStoreWidget.of(pageContext).connection,
messageId: message.id,
reactionType: ReactionType.unicodeEmoji,
emojiCode: '1f44d',
emojiName: '+1',
);
} catch (e) {
if (!pageContext.mounted) return;
Widget _buildButton({
required BuildContext context,
required EmojiCandidate emoji,
required bool isSelfVoted,
required bool isFirst,
}) {
final designVariables = DesignVariables.of(context);
return Flexible(child: InkWell(
onTap: () => _handleTapReaction(emoji: emoji, isSelfVoted: isSelfVoted),
splashFactory: NoSplash.splashFactory,
borderRadius: isFirst
? const BorderRadius.only(topLeft: Radius.circular(7))
: null,
overlayColor: WidgetStateColor.resolveWith((states) =>
states.any((e) => e == WidgetState.pressed)
? designVariables.contextMenuItemBg.withFadedAlpha(0.20)
: Colors.transparent),
child: Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 5),
alignment: Alignment.center,
color: isSelfVoted
? designVariables.contextMenuItemBg.withFadedAlpha(0.20)
: null,
child: UnicodeEmojiWidget(
emojiDisplay: emoji.emojiDisplay as UnicodeEmojiDisplay,
notoColorEmojiTextSize: 20.1,
size: 24))));
}

switch (e) {
case ZulipApiException():
errorMessage = e.message;
// TODO(#741) specific messages for common errors, like network errors
// (support with reusable code)
default:
}
@override
Widget build(BuildContext context) {
assert(EmojiStore.popularEmojiCandidates.every(
(emoji) => emoji.emojiType == ReactionType.unicodeEmoji));

showErrorDialog(context: pageContext,
title: 'Adding reaction failed', message: errorMessage);
final store = PerAccountStoreWidget.of(pageContext);
final designVariables = DesignVariables.of(context);

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

return Container(
decoration: BoxDecoration(color: designVariables.contextMenuItemBg.withFadedAlpha(0.12)),
child: Row(
spacing: 1,
children: List.unmodifiable(
EmojiStore.popularEmojiCandidates.mapIndexed((index, emoji) =>
_buildButton(
context: context,
emoji: emoji,
isSelfVoted: hasSelfVote(emoji),
isFirst: index == 0)))));
}
}

Expand Down
41 changes: 41 additions & 0 deletions lib/widgets/emoji_reaction.dart
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import 'package:flutter/material.dart';

import '../api/exception.dart';
import '../api/model/model.dart';
import '../api/route/messages.dart';
import '../model/emoji.dart';
import 'color.dart';
import 'dialog.dart';
import 'emoji.dart';
import 'store.dart';
import 'text.dart';
Expand Down Expand Up @@ -360,3 +362,42 @@ class _TextEmoji extends StatelessWidget {
text);
}
}

/// Adds or removes a reaction on the message corresponding to
/// the [messageId], showing an error dialog on failure.
/// Returns a Future resolving to true if operation succeeds.
Future<void> doAddOrRemoveReaction({
required BuildContext context,
required bool doRemoveReaction,
required int messageId,
required EmojiCandidate emoji,
required String errorDialogTitle,
}) async {
final store = PerAccountStoreWidget.of(context);
String? errorMessage;
try {
await (doRemoveReaction ? removeReaction : addReaction).call(
store.connection,
messageId: messageId,
reactionType: emoji.emojiType,
emojiCode: emoji.emojiCode,
emojiName: emoji.emojiName,
);
} catch (e) {
if (!context.mounted) return;

switch (e) {
case ZulipApiException():
errorMessage = e.message;
// TODO(#741) specific messages for common errors, like network errors
// (support with reusable code)
default:
// TODO(log)
}

showErrorDialog(context: context,
title: errorDialogTitle,
message: errorMessage);
return;
}
}
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');
}
Loading

0 comments on commit 5cf87f8

Please sign in to comment.