forked from zulip/zulip-flutter
-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
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
1 parent
be36153
commit abb7619
Showing
2 changed files
with
270 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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:'))); | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters