Skip to content

Commit

Permalink
reactions: Support adding arbitary reactions
Browse files Browse the repository at this point in the history
  • Loading branch information
rajveermalviya committed Dec 4, 2024
1 parent dc1bb71 commit 7d104af
Show file tree
Hide file tree
Showing 5 changed files with 349 additions and 23 deletions.
15 changes: 15 additions & 0 deletions lib/model/emoji.dart
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,21 @@ final class EmojiCandidate {
required List<String>? aliases,
required this.emojiDisplay,
}) : _aliases = aliases;

EmojiCandidate copyWith({
ReactionType? emojiType,
String? emojiCode,
String? emojiName,
List<String>? aliases,
EmojiDisplay? emojiDisplay,
}) {
return EmojiCandidate(
emojiType: emojiType ?? this.emojiType,
emojiCode: emojiCode ?? this.emojiCode,
emojiName: emojiName ?? this.emojiName,
aliases: aliases ?? _aliases,
emojiDisplay: emojiDisplay ?? this.emojiDisplay);
}
}

/// The portion of [PerAccountStore] describing what emoji exist.
Expand Down
74 changes: 51 additions & 23 deletions lib/widgets/action_sheet.dart
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ 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 Down Expand Up @@ -231,30 +232,57 @@ class ReactionButtons extends StatelessWidget {
}

return Container(
padding: const EdgeInsets.all(8),
padding: const EdgeInsets.only(left: 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));
})))
child: Row(children: [
Flexible(child: Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
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));
}))),
)),
TextButton(
onPressed: () {
showEmojiPickerSheet(context: pageContext, message: message);
},
style: TextButton.styleFrom(
padding: const EdgeInsets.fromLTRB(12, 12, 4, 12),
splashFactory: NoSplash.splashFactory,
foregroundColor: designVariables.contextMenuItemText,
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.zero)
).copyWith(backgroundColor: WidgetStateColor.resolveWith((states) =>
states.contains(WidgetState.pressed)
? designVariables.contextMenuItemBg.withFadedAlpha(0.20)
: Colors.transparent)),
child: Row(children: [
Text('other',
textAlign: TextAlign.right,
style: const TextStyle(fontSize: 14)
.merge(weightVariableTextStyle(context, wght: 600))),
Icon(ZulipIcons.chevron_right,
color: designVariables.contextMenuItemText,
size: 24),
])),
]),
);
}
}
Expand Down
235 changes: 235 additions & 0 deletions lib/widgets/emoji_reaction.dart
Original file line number Diff line number Diff line change
@@ -1,12 +1,16 @@
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 'inset_shadow.dart';
import 'store.dart';
import 'text.dart';
import 'theme.dart';

/// Emoji-reaction styles that differ between light and dark themes.
class EmojiReactionTheme extends ThemeExtension<EmojiReactionTheme> {
Expand Down Expand Up @@ -360,3 +364,234 @@ class _TextEmoji extends StatelessWidget {
text);
}
}


void showEmojiPickerSheet({required BuildContext context, required Message message}) {
final store = PerAccountStoreWidget.of(context);

showModalBottomSheet<void>(
context: context,
// Clip.hardEdge looks bad; Clip.antiAliasWithSaveLayer looks pixel-perfect
// on my iPhone 13 Pro but is marked as "much slower":
// https://api.flutter.dev/flutter/dart-ui/Clip.html
clipBehavior: Clip.antiAlias,
useSafeArea: true,
isScrollControlled: true,
builder: (BuildContext _) {
return SafeArea(
minimum: const EdgeInsets.only(bottom: 16),
// For _EmojiPickerItem, and RealmContentNetworkImage used in ImageEmojiWidget.
child: PerAccountStoreWidget(
accountId: store.accountId,
child: EmojiPicker(pageContext: context, message: message)));
});
}

class EmojiPicker extends StatefulWidget {
const EmojiPicker({
super.key,
required this.pageContext,
required this.message,
});

final BuildContext pageContext;
final Message message;

@override
State<EmojiPicker> createState() => _EmojiPickerState();
}

class _EmojiPickerState extends State<EmojiPicker> with PerAccountStoreAwareStateMixin<EmojiPicker> {
late List<EmojiCandidate> availableEmojiCandidates;
List<EmojiCandidate>? searchResults;

@override
void onNewStore() {
final store = PerAccountStoreWidget.of(context);
final allEmojiCandidates = store.allEmojiCandidates();

bool isPopularUnicodeEmoji(EmojiDisplay candidateEmojiDisplay) {
if (candidateEmojiDisplay is! UnicodeEmojiDisplay) return false;
return popularUnicodeEmojis.any(
(emoji) => emoji.emojiUnicode == candidateEmojiDisplay.emojiUnicode);
}

bool isSelfVoted(EmojiCandidate candidate) {
return widget.message.reactions?.aggregated.any((reactionWithVotes) =>
reactionWithVotes.reactionType == candidate.emojiType
&& reactionWithVotes.emojiCode == candidate.emojiCode
&& reactionWithVotes.userIds.contains(store.selfUserId)
) ?? false;
}

availableEmojiCandidates = allEmojiCandidates
.where((candidate) =>
!isSelfVoted(candidate) && !isPopularUnicodeEmoji(candidate.emojiDisplay))
.toList(growable: false);
searchResults = null;
}

// Adapted from web/src/emoji_picker.ts
void _filterEmojis(String query) {
if (query.isEmpty) {
setState(() { searchResults = null; });
return;
}

final results = <EmojiCandidate>[];
final searchTerms = query.toLowerCase().split(' ');
for (final candidate in availableEmojiCandidates) {
for (final alias in [candidate.emojiName, ...candidate.aliases]) {
if (searchTerms.every((e) => alias.contains(e))) {
results.add(candidate.copyWith(
emojiName: alias,
aliases: const []));
break; // We only need the first matching alias per emoji.
}
}

// Using query instead of searchTerms because it's possible multiple
// emojis were input without being separated by spaces.
final emojiDisplay = candidate.emojiDisplay;
if (emojiDisplay is UnicodeEmojiDisplay && query.contains(emojiDisplay.emojiUnicode)) {
results.add(candidate.copyWith(
emojiName: candidate.emojiName,
aliases: const []));
}
}

// TODO sort emojis

setState(() { searchResults = results; });
}

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

final emojiList = searchResults ?? availableEmojiCandidates;

return Column(children: [
Padding(
padding: const EdgeInsets.only(left: 8, top: 8),
child: Row(children: [
Flexible(child: TextField(
onChanged: (query) => _filterEmojis(query),
autofocus: false,
decoration: InputDecoration(
hintText: 'Search emoji',
contentPadding: const EdgeInsets.only(left: 10, top: 6),
filled: true,
fillColor: designVariables.bgSearchInput,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
borderSide: BorderSide.none)),
style: const TextStyle(fontSize: 19, height: 26 / 19)
.merge(weightVariableTextStyle(context, wght: 400)))),
TextButton(
onPressed: () => Navigator.pop(context),
style: TextButton.styleFrom(
padding: EdgeInsets.zero,
splashFactory: NoSplash.splashFactory,
foregroundColor: designVariables.contextMenuItemText,
).copyWith(backgroundColor: WidgetStateColor.resolveWith((states) =>
states.contains(WidgetState.pressed)
? designVariables.contextMenuItemBg.withFadedAlpha(0.20)
: Colors.transparent)),
child: Text('Close', style: const TextStyle(fontSize: 20, height: 30 / 20)
.merge(weightVariableTextStyle(context, wght: 400)))),
])),
Expanded(child: InsetShadowBox(
top: 8, bottom: 8,
color: designVariables.bgContextMenu,
child: ListView.builder(
padding: const EdgeInsets.symmetric(vertical: 8),
itemCount: emojiList.length,
itemBuilder: (context, i) => EmojiPickerItem(
pageContext: widget.pageContext,
candidate: emojiList[i],
message: widget.message),
),
)),
]);
}
}

@visibleForTesting
class EmojiPickerItem extends StatelessWidget {
const EmojiPickerItem({
super.key,
required this.pageContext,
required this.candidate,
required this.message,
});

final BuildContext pageContext;
final EmojiCandidate candidate;
final Message message;

static const _size = 24.0;
static const _notoColorEmojiTextSize = 24.0;

void _onPressed() async {
String? errorMessage;
try {
await addReaction(
PerAccountStoreWidget.of(pageContext).connection,
messageId: message.id,
reactionType: candidate.emojiType,
emojiCode: candidate.emojiCode,
emojiName: candidate.emojiName,
);
if (pageContext.mounted) Navigator.pop(pageContext); // Emoji picker
if (pageContext.mounted) Navigator.pop(pageContext); // Context menu
} catch (e) {
if (!pageContext.mounted) return;

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

showErrorDialog(context: pageContext,
title: 'Adding reaction failed',
message: errorMessage);
}
}

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

final emojiDisplay = candidate.emojiDisplay.resolve(store.userSettings);
final Widget? glyph = switch (emojiDisplay) {
ImageEmojiDisplay() =>
ImageEmojiWidget(size: _size, emojiDisplay: emojiDisplay),
UnicodeEmojiDisplay() =>
UnicodeEmojiWidget(
size: _size, notoColorEmojiTextSize: _notoColorEmojiTextSize,
emojiDisplay: emojiDisplay),
TextEmojiDisplay() => null, // The text is already shown separately.
};

final label = candidate.aliases.isEmpty
? candidate.emojiName
: [candidate.emojiName, ...candidate.aliases].join(", "); // TODO(#1080)

return TextButton.icon(
onPressed: _onPressed,
icon: glyph,
style: MenuItemButton.styleFrom(
alignment: Alignment.centerLeft,
shape: const RoundedRectangleBorder(),
splashFactory: NoSplash.splashFactory),
label: Text(label,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontSize: 17, height: 24 / 17)
.merge(weightVariableTextStyle(context, wght: 400))));
}
}
Loading

0 comments on commit 7d104af

Please sign in to comment.