Skip to content

Commit

Permalink
msglist: Show message reactions!
Browse files Browse the repository at this point in the history
And also support:
- removing a reaction you've already made, and
- joining in on existing reactions that other people have made.

It leaves out one part of zulip#125, for now:
- joining in on an existing reaction other people have made

As is our habit with the message list, this aims to be faithful to
the web app, as accessed today. That should be a good baseline to
make mobile-specific adjustments from. (In particular I think we'll
want larger touch targets.)

Unlike the web app, we use a font instead of a sprite sheet to
render Unicode emoji. This means that we, unlike web, have to
account for text-layout algorithms, and things like font metrics. So
if Unicode emoji appear noticeably differently from web, that's
worth being aware of.
  • Loading branch information
chrisbobbe committed Nov 22, 2023
1 parent be36153 commit abb7619
Show file tree
Hide file tree
Showing 2 changed files with 270 additions and 0 deletions.
267 changes: 267 additions & 0 deletions lib/widgets/emoji_reaction.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,267 @@
import 'dart:ui';

import 'package:flutter/material.dart';

import '../api/model/initial_snapshot.dart';
import '../api/model/model.dart';
import '../api/route/messages.dart';
import '../model/content.dart';
import 'content.dart';
import 'store.dart';
import 'text.dart';

class ReactionChipsList extends StatelessWidget {
final int messageId;
final Reactions reactions;

const ReactionChipsList({
super.key,
required this.messageId,
required this.reactions,
});

@override
Widget build(BuildContext context) {
final store = PerAccountStoreWidget.of(context);
final displayEmojiReactionUsers = store.userSettings?.displayEmojiReactionUsers ?? false;
final showNames = displayEmojiReactionUsers && reactions.total <= 3;

return Wrap(spacing: 4, runSpacing: 4, crossAxisAlignment: WrapCrossAlignment.center,
children: reactions.aggregated.map((reactionVotes) => ReactionChip(
showName: showNames,
messageId: messageId, reactionWithVotes: reactionVotes),
).toList());
}
}

final _textColorSelected = const HSLColor.fromAHSL(1, 210, 0.20, 0.20).toColor();
final _textColorUnselected = const HSLColor.fromAHSL(1, 210, 0.20, 0.25).toColor();

const _backgroundColorSelected = Colors.white;
// TODO shadow effect, following web, which uses `box-shadow: inset`:
// https://developer.mozilla.org/en-US/docs/Web/CSS/box-shadow#inset
// Needs Flutter support for something like that:
// https://github.com/flutter/flutter/issues/18636
// https://github.com/flutter/flutter/issues/52999
// Until then use a solid color; a much-lightened version of the shadow color.
// Also adapt by making [_borderColorUnselected] more transparent, so we'll
// want to check that against web when implementing the shadow.
final _backgroundColorUnselected = const HSLColor.fromAHSL(0.15, 210, 0.50, 0.875).toColor();

final _borderColorSelected = Colors.black.withOpacity(0.40);
// TODO see TODO on [_backgroundColorUnselected] about shadow effect
final _borderColorUnselected = Colors.black.withOpacity(0.06);

class ReactionChip extends StatelessWidget {
final bool showName;
final int messageId;
final ReactionWithVotes reactionWithVotes;

const ReactionChip({
super.key,
required this.showName,
required this.messageId,
required this.reactionWithVotes,
});

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

final reactionType = reactionWithVotes.reactionType;
final emojiCode = reactionWithVotes.emojiCode;
final emojiName = reactionWithVotes.emojiName;
final userIds = reactionWithVotes.userIds;

final emojiset = store.userSettings?.emojiset ?? Emojiset.google;

final selfUserId = store.account.userId;
final selfVoted = userIds.contains(selfUserId);
final label = showName
? userIds.map((id) {
return id == selfUserId
? 'You'
: store.users[id]?.fullName ?? '(unknown user)';
}).join(', ')
: userIds.length.toString();

final borderColor = selfVoted ? _borderColorSelected : _borderColorUnselected;
final labelColor = selfVoted ? _textColorSelected : _textColorUnselected;
final backgroundColor = selfVoted ? _backgroundColorSelected : _backgroundColorUnselected;
final splashColor = selfVoted ? _backgroundColorUnselected : _backgroundColorSelected;
final highlightColor = splashColor.withOpacity(0.5);

final borderSide = BorderSide(color: borderColor, width: 1);
final shape = StadiumBorder(side: borderSide);

return Tooltip(
excludeFromSemantics: true, // TODO: Semantics with eg "Reaction: <emoji name>; you and N others: <names>"
message: emojiName,
child: Material(
color: backgroundColor,
shape: shape,
child: InkWell(
customBorder: shape,
splashColor: splashColor,
highlightColor: highlightColor,
onTap: () {
(selfVoted ? removeReaction : addReaction).call(store.connection,
messageId: messageId,
reactionType: reactionType,
emojiCode: emojiCode,
emojiName: emojiName,
);
},
child: Padding(
// 1px of this padding accounts for the border, which Flutter
// just paints without changing size.
//
// Separately, web has 1px less than this on the left, but that
// asymmetry doesn't seem to help us.
padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 2),
child: Row(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.center, children: [
Padding(padding: const EdgeInsets.symmetric(horizontal: 3, vertical: 1),
child: (() {
if (emojiset == Emojiset.text) {
return _TextEmoji(emojiName: emojiName, selected: selfVoted);
}
switch (reactionType) {
case ReactionType.unicodeEmoji:
return _UnicodeEmoji(
emojiCode: emojiCode,
emojiName: emojiName,
selected: selfVoted,
);
case ReactionType.realmEmoji:
case ReactionType.zulipExtraEmoji:
return _ImageEmoji(
emojiCode: emojiCode,
emojiName: emojiName,
selected: selfVoted,
);
}
})()),
Flexible(
// Added vertical: 1 to give some space when the label is
// taller than the emoji (e.g. because it needs multiple lines)
child: Padding(padding: const EdgeInsets.symmetric(horizontal: 3, vertical: 1),
child: Text(
textWidthBasis: TextWidthBasis.longestLine,
style: TextStyle(
fontFamily: 'Source Sans 3',
fontSize: (14 * 0.90),
height: 13 / (14 * 0.90),
color: labelColor,
).merge(selfVoted
? weightVariableTextStyle(context, wght: 600, wghtIfPlatformRequestsBold: 900)
: weightVariableTextStyle(context)),
label)),
),
])))));
}
}

class _UnicodeEmoji extends StatelessWidget {
const _UnicodeEmoji({
required this.emojiCode,
required this.emojiName,
required this.selected,
});

final String emojiCode;
final String emojiName;
final bool selected;

@override
Widget build(BuildContext context) {
final parsed = tryParseEmojiCodeToUnicode(emojiCode);
if (parsed == null) {
return _TextEmoji(emojiName: emojiName, selected: selected);
}
final textScaler = MediaQuery.textScalerOf(context);
return SizedBox(
width: textScaler.scale(17),
child: Text(
style: const TextStyle(fontSize: 17),
strutStyle: const StrutStyle(fontSize: 17, forceStrutHeight: true),
parsed));
}
}

class _ImageEmoji extends StatelessWidget {
const _ImageEmoji({
required this.emojiCode,
required this.emojiName,
required this.selected,
});

final String emojiCode;
final String emojiName;
final bool selected;

Widget get _textFallback => _TextEmoji(emojiName: emojiName, selected: selected);

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

// Some people really dislike animated emoji.
final doNotAnimate = MediaQuery.of(context).disableAnimations
|| PlatformDispatcher.instance.accessibilityFeatures.reduceMotion;

String src;
switch (emojiCode) {
case 'zulip': // the single "zulip extra emoji"
src = '/static/generated/emoji/images/emoji/unicode/zulip.png';
default:
final item = store.realmEmoji[emojiCode];
if (item == null) {
return _textFallback;
}
src = doNotAnimate && item.stillUrl != null ? item.stillUrl! : item.sourceUrl;
}
final parsedSrc = Uri.tryParse(src);
if (parsedSrc == null) {
return _textFallback;
}
final resolved = store.account.realmUrl.resolveUri(parsedSrc);

final textScaler = MediaQuery.textScalerOf(context);

// Unicode emoji get scaled; it would look weird if image emoji didn't.
final size = textScaler.scale(17);

return Center(
child: RealmContentNetworkImage(
resolved,
width: size,
height: size,
errorBuilder: (context, _, __) => _textFallback,
),
);
}
}

class _TextEmoji extends StatelessWidget {
final String emojiName;
final bool selected;

const _TextEmoji({required this.emojiName, required this.selected});

@override
Widget build(BuildContext context) {
return SizedBox(
height: 17,
child: Center(
child: Text(
style: TextStyle(
fontFamily: 'Source Sans 3',
fontSize: 14 * 0.8,
height: 16 / (14 * 0.8),
color: selected ? _textColorSelected : _textColorUnselected,
).merge(selected
? weightVariableTextStyle(context, wght: 600, wghtIfPlatformRequestsBold: 900)
: weightVariableTextStyle(context)),
':$emojiName:')));
}
}
3 changes: 3 additions & 0 deletions lib/widgets/message_list.dart
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import 'action_sheet.dart';
import 'compose_box.dart';
import 'content.dart';
import 'dialog.dart';
import 'emoji_reaction.dart';
import 'icons.dart';
import 'page.dart';
import 'profile.dart';
Expand Down Expand Up @@ -696,6 +697,8 @@ class MessageWithPossibleSender extends StatelessWidget {
const SizedBox(height: 4),
],
MessageContent(message: message, content: item.content),
if ((message.reactions?.total ?? 0) > 0)
ReactionChipsList(messageId: message.id, reactions: message.reactions!)
])),
Container(
width: 80,
Expand Down

0 comments on commit abb7619

Please sign in to comment.