Skip to content

Commit 7d104af

Browse files
reactions: Support adding arbitary reactions
1 parent dc1bb71 commit 7d104af

File tree

5 files changed

+349
-23
lines changed

5 files changed

+349
-23
lines changed

lib/model/emoji.dart

+15
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,21 @@ final class EmojiCandidate {
120120
required List<String>? aliases,
121121
required this.emojiDisplay,
122122
}) : _aliases = aliases;
123+
124+
EmojiCandidate copyWith({
125+
ReactionType? emojiType,
126+
String? emojiCode,
127+
String? emojiName,
128+
List<String>? aliases,
129+
EmojiDisplay? emojiDisplay,
130+
}) {
131+
return EmojiCandidate(
132+
emojiType: emojiType ?? this.emojiType,
133+
emojiCode: emojiCode ?? this.emojiCode,
134+
emojiName: emojiName ?? this.emojiName,
135+
aliases: aliases ?? _aliases,
136+
emojiDisplay: emojiDisplay ?? this.emojiDisplay);
137+
}
123138
}
124139

125140
/// The portion of [PerAccountStore] describing what emoji exist.

lib/widgets/action_sheet.dart

+51-23
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import 'color.dart';
1818
import 'compose_box.dart';
1919
import 'dialog.dart';
2020
import 'emoji.dart';
21+
import 'emoji_reaction.dart';
2122
import 'icons.dart';
2223
import 'inset_shadow.dart';
2324
import 'message_list.dart';
@@ -231,30 +232,57 @@ class ReactionButtons extends StatelessWidget {
231232
}
232233

233234
return Container(
234-
padding: const EdgeInsets.all(8),
235+
padding: const EdgeInsets.only(left: 8),
235236
decoration: BoxDecoration(color: designVariables.contextMenuItemBg.withFadedAlpha(0.12)),
236-
child: Row(
237-
mainAxisAlignment: MainAxisAlignment.spaceAround,
238-
children: List.unmodifiable(popularUnicodeEmojis.map((emoji) {
239-
final selfVoted = hasSelfVote(emoji);
240-
return IconButton(
241-
onPressed: () => _onPressed(emoji, selfVoted),
242-
isSelected: selfVoted,
243-
style: IconButton.styleFrom(
244-
padding: EdgeInsets.zero,
245-
splashFactory: NoSplash.splashFactory,
246-
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(3.5)),
247-
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
248-
visualDensity: VisualDensity.compact,
249-
).copyWith(backgroundColor: WidgetStateColor.resolveWith((states) =>
250-
states.any((e) => e == WidgetState.pressed || e == WidgetState.selected)
251-
? designVariables.contextMenuItemBg.withFadedAlpha(0.20)
252-
: Colors.transparent)),
253-
icon: UnicodeEmojiWidget(
254-
emojiDisplay: emoji,
255-
notoColorEmojiTextSize: 24,
256-
size: 24));
257-
})))
237+
child: Row(children: [
238+
Flexible(child: Padding(
239+
padding: const EdgeInsets.symmetric(vertical: 8),
240+
child: Row(
241+
mainAxisAlignment: MainAxisAlignment.spaceAround,
242+
children: List.unmodifiable(popularUnicodeEmojis.map((emoji) {
243+
final selfVoted = hasSelfVote(emoji);
244+
return IconButton(
245+
onPressed: () => _onPressed(emoji, selfVoted),
246+
isSelected: selfVoted,
247+
style: IconButton.styleFrom(
248+
padding: EdgeInsets.zero,
249+
splashFactory: NoSplash.splashFactory,
250+
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(3.5)),
251+
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
252+
visualDensity: VisualDensity.compact,
253+
).copyWith(backgroundColor: WidgetStateColor.resolveWith((states) =>
254+
states.any((e) => e == WidgetState.pressed || e == WidgetState.selected)
255+
? designVariables.contextMenuItemBg.withFadedAlpha(0.20)
256+
: Colors.transparent)),
257+
icon: UnicodeEmojiWidget(
258+
emojiDisplay: emoji,
259+
notoColorEmojiTextSize: 24,
260+
size: 24));
261+
}))),
262+
)),
263+
TextButton(
264+
onPressed: () {
265+
showEmojiPickerSheet(context: pageContext, message: message);
266+
},
267+
style: TextButton.styleFrom(
268+
padding: const EdgeInsets.fromLTRB(12, 12, 4, 12),
269+
splashFactory: NoSplash.splashFactory,
270+
foregroundColor: designVariables.contextMenuItemText,
271+
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.zero)
272+
).copyWith(backgroundColor: WidgetStateColor.resolveWith((states) =>
273+
states.contains(WidgetState.pressed)
274+
? designVariables.contextMenuItemBg.withFadedAlpha(0.20)
275+
: Colors.transparent)),
276+
child: Row(children: [
277+
Text('other',
278+
textAlign: TextAlign.right,
279+
style: const TextStyle(fontSize: 14)
280+
.merge(weightVariableTextStyle(context, wght: 600))),
281+
Icon(ZulipIcons.chevron_right,
282+
color: designVariables.contextMenuItemText,
283+
size: 24),
284+
])),
285+
]),
258286
);
259287
}
260288
}

lib/widgets/emoji_reaction.dart

+235
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,16 @@
11
import 'package:flutter/material.dart';
22

3+
import '../api/exception.dart';
34
import '../api/model/model.dart';
45
import '../api/route/messages.dart';
56
import '../model/emoji.dart';
67
import 'color.dart';
8+
import 'dialog.dart';
79
import 'emoji.dart';
10+
import 'inset_shadow.dart';
811
import 'store.dart';
912
import 'text.dart';
13+
import 'theme.dart';
1014

1115
/// Emoji-reaction styles that differ between light and dark themes.
1216
class EmojiReactionTheme extends ThemeExtension<EmojiReactionTheme> {
@@ -360,3 +364,234 @@ class _TextEmoji extends StatelessWidget {
360364
text);
361365
}
362366
}
367+
368+
369+
void showEmojiPickerSheet({required BuildContext context, required Message message}) {
370+
final store = PerAccountStoreWidget.of(context);
371+
372+
showModalBottomSheet<void>(
373+
context: context,
374+
// Clip.hardEdge looks bad; Clip.antiAliasWithSaveLayer looks pixel-perfect
375+
// on my iPhone 13 Pro but is marked as "much slower":
376+
// https://api.flutter.dev/flutter/dart-ui/Clip.html
377+
clipBehavior: Clip.antiAlias,
378+
useSafeArea: true,
379+
isScrollControlled: true,
380+
builder: (BuildContext _) {
381+
return SafeArea(
382+
minimum: const EdgeInsets.only(bottom: 16),
383+
// For _EmojiPickerItem, and RealmContentNetworkImage used in ImageEmojiWidget.
384+
child: PerAccountStoreWidget(
385+
accountId: store.accountId,
386+
child: EmojiPicker(pageContext: context, message: message)));
387+
});
388+
}
389+
390+
class EmojiPicker extends StatefulWidget {
391+
const EmojiPicker({
392+
super.key,
393+
required this.pageContext,
394+
required this.message,
395+
});
396+
397+
final BuildContext pageContext;
398+
final Message message;
399+
400+
@override
401+
State<EmojiPicker> createState() => _EmojiPickerState();
402+
}
403+
404+
class _EmojiPickerState extends State<EmojiPicker> with PerAccountStoreAwareStateMixin<EmojiPicker> {
405+
late List<EmojiCandidate> availableEmojiCandidates;
406+
List<EmojiCandidate>? searchResults;
407+
408+
@override
409+
void onNewStore() {
410+
final store = PerAccountStoreWidget.of(context);
411+
final allEmojiCandidates = store.allEmojiCandidates();
412+
413+
bool isPopularUnicodeEmoji(EmojiDisplay candidateEmojiDisplay) {
414+
if (candidateEmojiDisplay is! UnicodeEmojiDisplay) return false;
415+
return popularUnicodeEmojis.any(
416+
(emoji) => emoji.emojiUnicode == candidateEmojiDisplay.emojiUnicode);
417+
}
418+
419+
bool isSelfVoted(EmojiCandidate candidate) {
420+
return widget.message.reactions?.aggregated.any((reactionWithVotes) =>
421+
reactionWithVotes.reactionType == candidate.emojiType
422+
&& reactionWithVotes.emojiCode == candidate.emojiCode
423+
&& reactionWithVotes.userIds.contains(store.selfUserId)
424+
) ?? false;
425+
}
426+
427+
availableEmojiCandidates = allEmojiCandidates
428+
.where((candidate) =>
429+
!isSelfVoted(candidate) && !isPopularUnicodeEmoji(candidate.emojiDisplay))
430+
.toList(growable: false);
431+
searchResults = null;
432+
}
433+
434+
// Adapted from web/src/emoji_picker.ts
435+
void _filterEmojis(String query) {
436+
if (query.isEmpty) {
437+
setState(() { searchResults = null; });
438+
return;
439+
}
440+
441+
final results = <EmojiCandidate>[];
442+
final searchTerms = query.toLowerCase().split(' ');
443+
for (final candidate in availableEmojiCandidates) {
444+
for (final alias in [candidate.emojiName, ...candidate.aliases]) {
445+
if (searchTerms.every((e) => alias.contains(e))) {
446+
results.add(candidate.copyWith(
447+
emojiName: alias,
448+
aliases: const []));
449+
break; // We only need the first matching alias per emoji.
450+
}
451+
}
452+
453+
// Using query instead of searchTerms because it's possible multiple
454+
// emojis were input without being separated by spaces.
455+
final emojiDisplay = candidate.emojiDisplay;
456+
if (emojiDisplay is UnicodeEmojiDisplay && query.contains(emojiDisplay.emojiUnicode)) {
457+
results.add(candidate.copyWith(
458+
emojiName: candidate.emojiName,
459+
aliases: const []));
460+
}
461+
}
462+
463+
// TODO sort emojis
464+
465+
setState(() { searchResults = results; });
466+
}
467+
468+
@override
469+
Widget build(BuildContext context) {
470+
final designVariables = DesignVariables.of(context);
471+
472+
final emojiList = searchResults ?? availableEmojiCandidates;
473+
474+
return Column(children: [
475+
Padding(
476+
padding: const EdgeInsets.only(left: 8, top: 8),
477+
child: Row(children: [
478+
Flexible(child: TextField(
479+
onChanged: (query) => _filterEmojis(query),
480+
autofocus: false,
481+
decoration: InputDecoration(
482+
hintText: 'Search emoji',
483+
contentPadding: const EdgeInsets.only(left: 10, top: 6),
484+
filled: true,
485+
fillColor: designVariables.bgSearchInput,
486+
border: OutlineInputBorder(
487+
borderRadius: BorderRadius.circular(10),
488+
borderSide: BorderSide.none)),
489+
style: const TextStyle(fontSize: 19, height: 26 / 19)
490+
.merge(weightVariableTextStyle(context, wght: 400)))),
491+
TextButton(
492+
onPressed: () => Navigator.pop(context),
493+
style: TextButton.styleFrom(
494+
padding: EdgeInsets.zero,
495+
splashFactory: NoSplash.splashFactory,
496+
foregroundColor: designVariables.contextMenuItemText,
497+
).copyWith(backgroundColor: WidgetStateColor.resolveWith((states) =>
498+
states.contains(WidgetState.pressed)
499+
? designVariables.contextMenuItemBg.withFadedAlpha(0.20)
500+
: Colors.transparent)),
501+
child: Text('Close', style: const TextStyle(fontSize: 20, height: 30 / 20)
502+
.merge(weightVariableTextStyle(context, wght: 400)))),
503+
])),
504+
Expanded(child: InsetShadowBox(
505+
top: 8, bottom: 8,
506+
color: designVariables.bgContextMenu,
507+
child: ListView.builder(
508+
padding: const EdgeInsets.symmetric(vertical: 8),
509+
itemCount: emojiList.length,
510+
itemBuilder: (context, i) => EmojiPickerItem(
511+
pageContext: widget.pageContext,
512+
candidate: emojiList[i],
513+
message: widget.message),
514+
),
515+
)),
516+
]);
517+
}
518+
}
519+
520+
@visibleForTesting
521+
class EmojiPickerItem extends StatelessWidget {
522+
const EmojiPickerItem({
523+
super.key,
524+
required this.pageContext,
525+
required this.candidate,
526+
required this.message,
527+
});
528+
529+
final BuildContext pageContext;
530+
final EmojiCandidate candidate;
531+
final Message message;
532+
533+
static const _size = 24.0;
534+
static const _notoColorEmojiTextSize = 24.0;
535+
536+
void _onPressed() async {
537+
String? errorMessage;
538+
try {
539+
await addReaction(
540+
PerAccountStoreWidget.of(pageContext).connection,
541+
messageId: message.id,
542+
reactionType: candidate.emojiType,
543+
emojiCode: candidate.emojiCode,
544+
emojiName: candidate.emojiName,
545+
);
546+
if (pageContext.mounted) Navigator.pop(pageContext); // Emoji picker
547+
if (pageContext.mounted) Navigator.pop(pageContext); // Context menu
548+
} catch (e) {
549+
if (!pageContext.mounted) return;
550+
551+
switch (e) {
552+
case ZulipApiException():
553+
errorMessage = e.message;
554+
// TODO(#741) specific messages for common errors, like network errors
555+
// (support with reusable code)
556+
default:
557+
}
558+
559+
showErrorDialog(context: pageContext,
560+
title: 'Adding reaction failed',
561+
message: errorMessage);
562+
}
563+
}
564+
565+
@override
566+
Widget build(BuildContext context) {
567+
final store = PerAccountStoreWidget.of(context);
568+
569+
final emojiDisplay = candidate.emojiDisplay.resolve(store.userSettings);
570+
final Widget? glyph = switch (emojiDisplay) {
571+
ImageEmojiDisplay() =>
572+
ImageEmojiWidget(size: _size, emojiDisplay: emojiDisplay),
573+
UnicodeEmojiDisplay() =>
574+
UnicodeEmojiWidget(
575+
size: _size, notoColorEmojiTextSize: _notoColorEmojiTextSize,
576+
emojiDisplay: emojiDisplay),
577+
TextEmojiDisplay() => null, // The text is already shown separately.
578+
};
579+
580+
final label = candidate.aliases.isEmpty
581+
? candidate.emojiName
582+
: [candidate.emojiName, ...candidate.aliases].join(", "); // TODO(#1080)
583+
584+
return TextButton.icon(
585+
onPressed: _onPressed,
586+
icon: glyph,
587+
style: MenuItemButton.styleFrom(
588+
alignment: Alignment.centerLeft,
589+
shape: const RoundedRectangleBorder(),
590+
splashFactory: NoSplash.splashFactory),
591+
label: Text(label,
592+
maxLines: 2,
593+
overflow: TextOverflow.ellipsis,
594+
style: const TextStyle(fontSize: 17, height: 24 / 17)
595+
.merge(weightVariableTextStyle(context, wght: 400))));
596+
}
597+
}

0 commit comments

Comments
 (0)