diff --git a/docs/legal/THIRD_PARTY_NOTICES.md b/docs/legal/THIRD_PARTY_NOTICES.md index 66f5db5..5e99d02 100644 --- a/docs/legal/THIRD_PARTY_NOTICES.md +++ b/docs/legal/THIRD_PARTY_NOTICES.md @@ -7,6 +7,10 @@ The following external test data is included: - `test/vectors/nist_mlkem768.json` and `test/vectors/nist_mldsa65.json`: NIST post-quantum known-answer vectors. Source: https://github.com/usnistgov/ACVP-Server. - `test/vectors/wycheproof_classical.json`: the fixture metadata identifies Google Wycheproof / C2SP testvectors_v1, licensed Apache-2.0. Source: https://github.com/C2SP/wycheproof. +The following external data is included in the app: + +- `lib/src/model/chat/emoji_catalog_data.dart`: emoji, their names and English search keywords, generated by `scripts/gen_emoji_catalog.py` from Unicode `emoji-test.txt` 15.0 and CLDR 42 annotations. Copyright ยฉ Unicode, Inc., used under the Unicode License v3. Source: https://www.unicode.org/license.txt. + Use `dart pub deps --style=list` to inspect resolved dependencies and check their licence notices when changing packages. This file does not replace those notices. The repository's `LICENSE` covers Fireplace code. The name, logo and artwork are addressed separately in [`TRADEMARKS.md`](TRADEMARKS.md). diff --git a/docs/reviews/messaging-fidelity/README.md b/docs/reviews/messaging-fidelity/README.md new file mode 100644 index 0000000..64c6ef5 --- /dev/null +++ b/docs/reviews/messaging-fidelity/README.md @@ -0,0 +1,114 @@ +# Chat list, held-message menu, emoji picker and reply bubbles + +This change rebuilds four surfaces against the owner's reference screenshots +(a Telegram chat list and Signal's held-message menu, emoji picker and quoted +replies), keeping Fireplace's colours, protocol and privacy rules. Sizes were +measured from the screenshots pixel by pixel and cross-checked with the +constants in the public Signal-iOS source. No wire format, key handling, rule, +server field or package changed. + +## What changed + +- **Chat list.** A 104-point row: a 60-point orange avatar with white initials + (no photos), the name and a short date on one line, a two-line preview, and + a filled unread count beside the second line. Counts still come from the + decrypted local history. The row's menu button is gone; mute is a long press, + a right click, the context-menu key, or a screen-reader action. +- **Held-message menu.** A 320 x 56 reaction bar, the message, and a 250-point + action list with 50-point rows, twelve points apart and sixteen from the + edge. A message with room for its menu does not move. One too low for it + slides up only as far as needed and slides back when the menu closes; the + conversation underneath never scrolls. +- **Emoji picker.** The "more" button swaps the bar and list for a sheet in + the same overlay: a search field, recent reactions, eight categories in a + seven-column grid and a category bar. It rests at about half the screen, + opens fully when searching or pulled up, and closes when dragged down, + tapped outside or on Escape. Search runs over a table bundled with the app. +- **Replies.** A reply bubble is as wide as the wider of its quote and its own + text, so a short reply stays small. The quote is a tinted panel with a + four-point stripe, the author and up to two lines of the original. + +## Renders + +Real widgets, fictional fixtures, 375 x 812 points at three pixels per point, +UTC, with the installed Windows emoji font as a test-only fallback. + +| State | Screenshot | +| --- | --- | +| Chat list | [Dark](chat-list-dark.png) ยท [Light](chat-list-light.png) | +| Held message, received | [Dark](message-menu-incoming-dark.png) | +| Held message, sent | [Light](message-menu-outgoing-light.png) | +| Emoji picker with recents | [Dark](emoji-picker-dark.png) | +| Emoji search | [Light](emoji-search-light.png) | +| Replies of different lengths | [Dark](replies-dark.png) ยท [Light](replies-light.png) | + +Side-by-side and overlay comparisons were made with +`scripts/render/compare_reference.py`. They are not in the repository because +the reference screenshots show other people's names, photos and messages. + +## Measured values + +| Element | Reference | This change | +| --- | --- | --- | +| Chat row height, avatar | 104.2, 60 | 104 + hairline, 60 | +| Avatar and text insets | 16, 86.7 | 16, 86 | +| Name / preview / date type | 21, 20, 19 points | same | +| Preview baselines from row top | 57.5, 84.5 | 57.5, 84.5 | +| Unread count | 26-27 circle, 16 from edge | 26, 16 | +| Reaction bar | 320 x 56, 44-point cells, 6 padding | same | +| Gaps bar / message / list | 12, 12 | 12, 12 | +| Action list | 250 wide, 50-point rows, 22-point label | same | +| Emoji sheet top | 373 of 812 | 372.7 | +| Emoji cell, column and row pitch | 38, 50.8, 48 | 38, 50.83, 48 | +| Category button and pitch | 40, 44 | 40, 44 | +| Quote stripe, corner, inset | 4, 10, 8 | 4, 10, 8 | + +## Differences that remain + +- **Typeface.** Renders use Roboto; an iPhone uses San Francisco. Positions and + sizes match, letter shapes and widths do not. On a phone the system font is + used. +- **Emoji artwork** is the platform's own. +- **Icons** are Cupertino icons, close to but not the same drawings as the + reference's system symbols. The category bar's food and symbols icons are + Material ones. +- **Text size.** The references were taken with enlarged text. Their sizes are + used at the standard text scale; larger system text scales them further. +- **Chat list.** Initials replace photos and the orange replaces per-person + colours. A conversation with no messages shows "Encrypted chat" where the + reference row is empty. Muted and security-warning marks sit after the name. +- **Menu actions** are Fireplace's own (Reply, Edit, Forward, Copy, Select + text, Select, then Delete for everyone or Report); there is no Info or Pin. +- **Emoji picker.** No skin-tone settings button, so the search field spans the + sheet. 46 emoji that the existing encrypted reaction format rejects are left + out, as are skin-tone variants. A held message the sheet would cover rises to + stay visible; in the reference it stays partly covered. +- **Replies.** The time stays on its own line under the text; Signal puts it + beside the last line. +- **Timeline inset** is now 16 points (was 12) so a held message does not shift + sideways when lifted. + +## Validation + +Pinned Flutter 3.47.6, Windows host. + +| Check | Result | +| --- | --- | +| `dart format` and `flutter analyze` | Clean | +| Full non-timing suite | 883 passed, 1 existing skip | +| Timing suite, one at a time | 17 passed | +| Render catalog | 180 passed | +| Layer check and checker tests | Passed | + +New tests cover row geometry, initials, unread counts from local history, +muting by long press and by screen reader, the date label, menu geometry, a +held message staying put or rising and returning, Back after the menu closes, +the picker's layout, local search, recents, dismissal, keyboard inset and +enlarged text, the emoji table against reaction validation, and reply sizing. + +Not done: physical-device acceptance on iOS or Android. + +## AI assistance + +Claude Code (Anthropic) wrote the implementation, tests and this report. A +human must review it before merge. diff --git a/docs/reviews/messaging-fidelity/chat-list-dark.png b/docs/reviews/messaging-fidelity/chat-list-dark.png new file mode 100644 index 0000000..fee422a Binary files /dev/null and b/docs/reviews/messaging-fidelity/chat-list-dark.png differ diff --git a/docs/reviews/messaging-fidelity/chat-list-light.png b/docs/reviews/messaging-fidelity/chat-list-light.png new file mode 100644 index 0000000..494a7f1 Binary files /dev/null and b/docs/reviews/messaging-fidelity/chat-list-light.png differ diff --git a/docs/reviews/messaging-fidelity/emoji-picker-dark.png b/docs/reviews/messaging-fidelity/emoji-picker-dark.png new file mode 100644 index 0000000..f5ba2b4 Binary files /dev/null and b/docs/reviews/messaging-fidelity/emoji-picker-dark.png differ diff --git a/docs/reviews/messaging-fidelity/emoji-search-light.png b/docs/reviews/messaging-fidelity/emoji-search-light.png new file mode 100644 index 0000000..4b56190 Binary files /dev/null and b/docs/reviews/messaging-fidelity/emoji-search-light.png differ diff --git a/docs/reviews/messaging-fidelity/message-menu-incoming-dark.png b/docs/reviews/messaging-fidelity/message-menu-incoming-dark.png new file mode 100644 index 0000000..2f17e4c Binary files /dev/null and b/docs/reviews/messaging-fidelity/message-menu-incoming-dark.png differ diff --git a/docs/reviews/messaging-fidelity/message-menu-outgoing-light.png b/docs/reviews/messaging-fidelity/message-menu-outgoing-light.png new file mode 100644 index 0000000..3c211d4 Binary files /dev/null and b/docs/reviews/messaging-fidelity/message-menu-outgoing-light.png differ diff --git a/docs/reviews/messaging-fidelity/replies-dark.png b/docs/reviews/messaging-fidelity/replies-dark.png new file mode 100644 index 0000000..d5dd0f9 Binary files /dev/null and b/docs/reviews/messaging-fidelity/replies-dark.png differ diff --git a/docs/reviews/messaging-fidelity/replies-light.png b/docs/reviews/messaging-fidelity/replies-light.png new file mode 100644 index 0000000..10c2ead Binary files /dev/null and b/docs/reviews/messaging-fidelity/replies-light.png differ diff --git a/lib/src/model/chat/emoji_catalog.dart b/lib/src/model/chat/emoji_catalog.dart new file mode 100644 index 0000000..31b7919 --- /dev/null +++ b/lib/src/model/chat/emoji_catalog.dart @@ -0,0 +1,76 @@ +import 'package:fireplace/src/model/chat/emoji_catalog_data.dart'; + +/// One emoji with the words that find it. +class EmojiEntry { + const EmojiEntry(this.emoji, this.name, this.keywords); + final String emoji, name; + final List keywords; +} + +class EmojiCategory { + const EmojiCategory(this.name, this.entries); + final String name; + final List entries; +} + +/// The bundled emoji, grouped as the picker shows them. Parsed on first use; +/// nothing is fetched and no query leaves the device. +final List emojiCategories = _parse(); + +List _parse() { + final categories = []; + var entries = []; + for (final line in emojiCatalogData.split('\n')) { + if (line.isEmpty) continue; + // The keycap emoji also starts with '#', but its line has tabs. + if (line.startsWith('#') && !line.contains(' ')) { + entries = []; + categories.add(EmojiCategory(line.substring(1), entries)); + continue; + } + final parts = line.split('\t'); + entries.add(EmojiEntry(parts[0], parts[1], parts.sublist(2))); + } + return List.unmodifiable(categories); +} + +const emojiSearchLimit = 32; + +/// Emoji whose name or keywords match every word typed. An exact name comes +/// first, then names that start with the query, other name matches, and +/// keyword matches. +/// The query is capped and the scan is one pass over a fixed table. +List searchEmoji(String query, {int limit = 120}) { + final trimmed = query.trim().toLowerCase(); + final words = + (trimmed.length > emojiSearchLimit + ? trimmed.substring(0, emojiSearchLimit) + : trimmed) + .split(RegExp(r'\s+')) + .where((word) => word.isNotEmpty) + .toList(); + if (words.isEmpty) return const []; + final joined = words.join(' '); + final exact = [], + prefix = [], + named = [], + tagged = []; + for (final category in emojiCategories) { + for (final entry in category.entries) { + if (entry.name == joined) { + exact.add(entry); + } else if (entry.name.startsWith(joined)) { + prefix.add(entry); + } else if (words.every(entry.name.contains)) { + named.add(entry); + } else if (words.every( + (word) => + entry.name.contains(word) || + entry.keywords.any((keyword) => keyword.startsWith(word)), + )) { + tagged.add(entry); + } + } + } + return [...exact, ...prefix, ...named, ...tagged].take(limit).toList(); +} diff --git a/lib/src/model/chat/emoji_catalog_data.dart b/lib/src/model/chat/emoji_catalog_data.dart new file mode 100644 index 0000000..cadd0a0 --- /dev/null +++ b/lib/src/model/chat/emoji_catalog_data.dart @@ -0,0 +1,1843 @@ +// GENERATED by scripts/gen_emoji_catalog.py from Unicode emoji-test.txt 15.0 and +// CLDR 42 English annotations. Do not edit by hand. Skin-tone variants and +// sequences that reaction validation does not accept are left out. +// Data (c) Unicode, Inc., used under the Unicode License v3; see +// docs/legal/THIRD_PARTY_NOTICES.md. +// dart format off + +/// One emoji per line: the emoji, its name, then search keywords, separated +/// by tabs. A line starting with '#' names the category that follows. +const emojiCatalogData = r''' +#Smileys & People +๐Ÿ˜€ grinning face face grin +๐Ÿ˜ƒ grinning face with big eyes face mouth open smile +๐Ÿ˜„ grinning face with smiling eyes eye face mouth open smile +๐Ÿ˜ beaming face with smiling eyes eye face grin smile +๐Ÿ˜† grinning squinting face face laugh mouth satisfied smile +๐Ÿ˜… grinning face with sweat cold face open smile sweat +๐Ÿคฃ rolling on the floor laughing face floor laugh rofl rolling rotfl +๐Ÿ˜‚ face with tears of joy face joy laugh tear +๐Ÿ™‚ slightly smiling face face smile +๐Ÿ™ƒ upside-down face face upside-down +๐Ÿซ  melting face disappear dissolve liquid melt +๐Ÿ˜‰ winking face face wink +๐Ÿ˜Š smiling face with smiling eyes blush eye face smile +๐Ÿ˜‡ smiling face with halo angel face fantasy halo innocent +๐Ÿฅฐ smiling face with hearts adore crush hearts in love +๐Ÿ˜ smiling face with heart-eyes eye face love smile +๐Ÿคฉ star-struck eyes face grinning star +๐Ÿ˜˜ face blowing a kiss face kiss +๐Ÿ˜— kissing face face kiss +โ˜บ๏ธ smiling face face outlined relaxed smile +๐Ÿ˜š kissing face with closed eyes closed eye face kiss +๐Ÿ˜™ kissing face with smiling eyes eye face kiss smile +๐Ÿฅฒ smiling face with tear grateful proud relieved smiling tear touched +๐Ÿ˜‹ face savoring food delicious face savouring smile yum +๐Ÿ˜› face with tongue face tongue +๐Ÿ˜œ winking face with tongue eye face joke tongue wink +๐Ÿคช zany face eye goofy large small +๐Ÿ˜ squinting face with tongue eye face horrible taste tongue +๐Ÿค‘ money-mouth face face money mouth +๐Ÿค— smiling face with open hands face hug hugging open hands smiling face +๐Ÿคญ face with hand over mouth whoops +๐Ÿซข face with open eyes and hand over mouth amazement awe disbelief embarrass scared surprise +๐Ÿซฃ face with peeking eye captivated peep stare +๐Ÿคซ shushing face quiet shush +๐Ÿค” thinking face face thinking +๐Ÿซก saluting face ok salute sunny troops yes +๐Ÿค zipper-mouth face face mouth zipper +๐Ÿคจ face with raised eyebrow distrust skeptic +๐Ÿ˜ neutral face deadpan face meh neutral +๐Ÿ˜‘ expressionless face expressionless face inexpressive meh unexpressive +๐Ÿ˜ถ face without mouth face mouth quiet silent +๐Ÿซฅ dotted line face depressed disappear hide introvert invisible +๐Ÿ˜ถโ€๐ŸŒซ๏ธ face in clouds absentminded face in the fog head in clouds +๐Ÿ˜ smirking face face smirk +๐Ÿ˜’ unamused face face unamused unhappy +๐Ÿ™„ face with rolling eyes eyeroll eyes face rolling +๐Ÿ˜ฌ grimacing face face grimace +๐Ÿ˜ฎโ€๐Ÿ’จ face exhaling exhale gasp groan relief whisper whistle +๐Ÿคฅ lying face face lie pinocchio +๐Ÿซจ shaking face earthquake face shaking shock vibrate +๐Ÿ˜Œ relieved face face relieved +๐Ÿ˜” pensive face dejected face pensive +๐Ÿ˜ช sleepy face face good night sleep +๐Ÿคค drooling face drooling face +๐Ÿ˜ด sleeping face face good night sleep zzz +๐Ÿ˜ท face with medical mask cold doctor face mask sick +๐Ÿค’ face with thermometer face ill sick thermometer +๐Ÿค• face with head-bandage bandage face hurt injury +๐Ÿคข nauseated face face nauseated vomit +๐Ÿคฎ face vomiting puke sick vomit +๐Ÿคง sneezing face face gesundheit sneeze +๐Ÿฅต hot face feverish heat stroke hot red-faced sweating +๐Ÿฅถ cold face blue-faced cold freezing frostbite icicles +๐Ÿฅด woozy face dizzy intoxicated tipsy uneven eyes wavy mouth +๐Ÿ˜ต face with crossed-out eyes crossed-out eyes dead face knocked out +๐Ÿ˜ตโ€๐Ÿ’ซ face with spiral eyes dizzy hypnotized spiral trouble whoa +๐Ÿคฏ exploding head mind blown shocked +๐Ÿค  cowboy hat face cowboy cowgirl face hat +๐Ÿฅณ partying face celebration hat horn party +๐Ÿฅธ disguised face disguise face glasses incognito nose +๐Ÿ˜Ž smiling face with sunglasses bright cool face sun sunglasses +๐Ÿค“ nerd face face geek nerd +๐Ÿง face with monocle face monocle stuffy +๐Ÿ˜• confused face confused face meh +๐Ÿซค face with diagonal mouth disappointed meh skeptical unsure +๐Ÿ˜Ÿ worried face face worried +๐Ÿ™ slightly frowning face face frown +โ˜น๏ธ frowning face face frown +๐Ÿ˜ฎ face with open mouth face mouth open sympathy +๐Ÿ˜ฏ hushed face face hushed stunned surprised +๐Ÿ˜ฒ astonished face astonished face shocked totally +๐Ÿ˜ณ flushed face dazed face flushed +๐Ÿฅบ pleading face begging mercy puppy eyes +๐Ÿฅน face holding back tears angry cry proud resist sad +๐Ÿ˜ฆ frowning face with open mouth face frown mouth open +๐Ÿ˜ง anguished face anguished face +๐Ÿ˜จ fearful face face fear fearful scared +๐Ÿ˜ฐ anxious face with sweat blue cold face rushed sweat +๐Ÿ˜ฅ sad but relieved face disappointed face relieved whew +๐Ÿ˜ข crying face cry face sad tear +๐Ÿ˜ญ loudly crying face cry face sad sob tear +๐Ÿ˜ฑ face screaming in fear face fear munch scared scream +๐Ÿ˜– confounded face confounded face +๐Ÿ˜ฃ persevering face face persevere +๐Ÿ˜ž disappointed face disappointed face +๐Ÿ˜“ downcast face with sweat cold face sweat +๐Ÿ˜ฉ weary face face tired weary +๐Ÿ˜ซ tired face face tired +๐Ÿฅฑ yawning face bored tired yawn +๐Ÿ˜ค face with steam from nose face triumph won +๐Ÿ˜ก enraged face angry enraged face mad pouting rage red +๐Ÿ˜  angry face anger angry face mad +๐Ÿคฌ face with symbols on mouth swearing +๐Ÿ˜ˆ smiling face with horns face fairy tale fantasy horns smile +๐Ÿ‘ฟ angry face with horns demon devil face fantasy imp +๐Ÿ’€ skull death face fairy tale monster +โ˜ ๏ธ skull and crossbones crossbones death face monster skull +๐Ÿ’ฉ pile of poo dung face monster poo poop +๐Ÿคก clown face clown face +๐Ÿ‘น ogre creature face fairy tale fantasy monster +๐Ÿ‘บ goblin creature face fairy tale fantasy monster +๐Ÿ‘ป ghost creature face fairy tale fantasy monster +๐Ÿ‘ฝ alien creature extraterrestrial face fantasy ufo +๐Ÿ‘พ alien monster alien creature extraterrestrial face monster ufo +๐Ÿค– robot face monster +๐Ÿ˜บ grinning cat cat face grinning mouth open smile +๐Ÿ˜ธ grinning cat with smiling eyes cat eye face grin smile +๐Ÿ˜น cat with tears of joy cat face joy tear +๐Ÿ˜ป smiling cat with heart-eyes cat eye face heart love smile +๐Ÿ˜ผ cat with wry smile cat face ironic smile wry +๐Ÿ˜ฝ kissing cat cat eye face kiss +๐Ÿ™€ weary cat cat face oh surprised weary +๐Ÿ˜ฟ crying cat cat cry face sad tear +๐Ÿ˜พ pouting cat cat face pouting +๐Ÿ™ˆ see-no-evil monkey evil face forbidden monkey see +๐Ÿ™‰ hear-no-evil monkey evil face forbidden hear monkey +๐Ÿ™Š speak-no-evil monkey evil face forbidden monkey speak +๐Ÿ’Œ love letter heart letter love mail +๐Ÿ’˜ heart with arrow arrow cupid +๐Ÿ’ heart with ribbon ribbon valentine +๐Ÿ’– sparkling heart excited sparkle +๐Ÿ’— growing heart excited growing nervous pulse +๐Ÿ’“ beating heart beating heartbeat pulsating +๐Ÿ’ž revolving hearts revolving +๐Ÿ’• two hearts love +๐Ÿ’Ÿ heart decoration heart +โฃ๏ธ heart exclamation exclamation mark punctuation +๐Ÿ’” broken heart break broken +โค๏ธโ€๐Ÿ”ฅ heart on fire burn heart love lust sacred heart +โค๏ธโ€๐Ÿฉน mending heart healthier improving mending recovering recuperating well +โค๏ธ red heart heart +๐Ÿฉท pink heart cute heart like love pink +๐Ÿงก orange heart orange +๐Ÿ’› yellow heart yellow +๐Ÿ’š green heart green +๐Ÿ’™ blue heart blue +๐Ÿฉต light blue heart cyan heart light blue teal +๐Ÿ’œ purple heart purple +๐ŸคŽ brown heart brown heart +๐Ÿ–ค black heart black evil wicked +๐Ÿฉถ grey heart gray heart silver slate +๐Ÿค white heart heart white +๐Ÿ’‹ kiss mark kiss lips +๐Ÿ’ฏ hundred points 100 full hundred score +๐Ÿ’ข anger symbol angry comic mad +๐Ÿ’ฅ collision boom comic +๐Ÿ’ซ dizzy comic star +๐Ÿ’ฆ sweat droplets comic splashing sweat +๐Ÿ’จ dashing away comic dash running +๐Ÿ•ณ๏ธ hole +๐Ÿ’ฌ speech balloon balloon bubble comic dialog speech +๐Ÿ‘๏ธโ€๐Ÿ—จ๏ธ eye in speech bubble balloon bubble eye speech witness +๐Ÿ—จ๏ธ left speech bubble balloon bubble dialog speech +๐Ÿ—ฏ๏ธ right anger bubble angry balloon bubble mad +๐Ÿ’ญ thought balloon balloon bubble comic thought +๐Ÿ’ค zzz comic good night sleep +๐Ÿ‘‹ waving hand hand wave waving +๐Ÿคš raised back of hand backhand raised +๐Ÿ–๏ธ hand with fingers splayed finger hand splayed +โœ‹ raised hand hand high 5 high five +๐Ÿ–– vulcan salute finger hand spock vulcan +๐Ÿซฑ rightwards hand hand right rightward +๐Ÿซฒ leftwards hand hand left leftward +๐Ÿซณ palm down hand dismiss drop shoo +๐Ÿซด palm up hand beckon catch come offer +๐Ÿซท leftwards pushing hand high five leftward push refuse stop wait +๐Ÿซธ rightwards pushing hand high five push refuse rightward stop wait +๐Ÿ‘Œ ok hand hand ok +๐ŸคŒ pinched fingers fingers hand gesture interrogation pinched sarcastic +๐Ÿค pinching hand small amount +โœŒ๏ธ victory hand hand v victory +๐Ÿคž crossed fingers cross finger hand luck +๐Ÿซฐ hand with index finger and thumb crossed expensive heart love money snap +๐ŸคŸ love-you gesture hand ily +๐Ÿค˜ sign of the horns finger hand horns rock-on +๐Ÿค™ call me hand call hand hang loose shaka +๐Ÿ‘ˆ backhand index pointing left backhand finger hand index point +๐Ÿ‘‰ backhand index pointing right backhand finger hand index point +๐Ÿ‘† backhand index pointing up backhand finger hand point up +๐Ÿ–• middle finger finger hand +๐Ÿ‘‡ backhand index pointing down backhand down finger hand point +โ˜๏ธ index pointing up finger hand index point up +๐Ÿซต index pointing at the viewer point you +๐Ÿ‘ thumbs up +1 hand thumb up +๐Ÿ‘Ž thumbs down -1 down hand thumb +โœŠ raised fist clenched fist hand punch +๐Ÿ‘Š oncoming fist clenched fist hand punch +๐Ÿค› left-facing fist fist leftwards +๐Ÿคœ right-facing fist fist rightwards +๐Ÿ‘ clapping hands clap hand +๐Ÿ™Œ raising hands celebration gesture hand hooray raised +๐Ÿซถ heart hands love +๐Ÿ‘ open hands hand open +๐Ÿคฒ palms up together prayer +๐Ÿค handshake agreement hand meeting shake +๐Ÿ™ folded hands ask hand high 5 high five please pray thanks +โœ๏ธ writing hand hand write +๐Ÿ’… nail polish care cosmetics manicure nail polish +๐Ÿคณ selfie camera phone +๐Ÿ’ช flexed biceps biceps comic flex muscle +๐Ÿฆพ mechanical arm accessibility prosthetic +๐Ÿฆฟ mechanical leg accessibility prosthetic +๐Ÿฆต leg kick limb +๐Ÿฆถ foot kick stomp +๐Ÿ‘‚ ear body +๐Ÿฆป ear with hearing aid accessibility hard of hearing +๐Ÿ‘ƒ nose body +๐Ÿง  brain intelligent +๐Ÿซ€ anatomical heart anatomical cardiology heart organ pulse +๐Ÿซ lungs breath exhalation inhalation organ respiration +๐Ÿฆท tooth dentist +๐Ÿฆด bone skeleton +๐Ÿ‘€ eyes eye face +๐Ÿ‘๏ธ eye body +๐Ÿ‘… tongue body +๐Ÿ‘„ mouth lips +๐Ÿซฆ biting lip anxious fear flirting nervous uncomfortable worried +๐Ÿ‘ถ baby young +๐Ÿง’ child gender-neutral unspecified gender young +๐Ÿ‘ฆ boy young +๐Ÿ‘ง girl virgo young zodiac +๐Ÿง‘ person adult gender-neutral unspecified gender +๐Ÿ‘ฑ person: blond hair blond blond-haired person hair +๐Ÿ‘จ man adult +๐Ÿง” person: beard beard person +๐Ÿง”โ€โ™‚๏ธ man: beard beard man +๐Ÿง”โ€โ™€๏ธ woman: beard beard woman +๐Ÿ‘จโ€๐Ÿฆฐ man: red hair adult man red hair +๐Ÿ‘จโ€๐Ÿฆฑ man: curly hair adult curly hair man +๐Ÿ‘จโ€๐Ÿฆณ man: white hair adult man white hair +๐Ÿ‘จโ€๐Ÿฆฒ man: bald adult bald man +๐Ÿ‘ฉ woman adult +๐Ÿ‘ฉโ€๐Ÿฆฐ woman: red hair adult red hair woman +๐Ÿง‘โ€๐Ÿฆฐ person: red hair adult gender-neutral person red hair unspecified gender +๐Ÿ‘ฉโ€๐Ÿฆฑ woman: curly hair adult curly hair woman +๐Ÿง‘โ€๐Ÿฆฑ person: curly hair adult curly hair gender-neutral person unspecified gender +๐Ÿ‘ฉโ€๐Ÿฆณ woman: white hair adult white hair woman +๐Ÿง‘โ€๐Ÿฆณ person: white hair adult gender-neutral person unspecified gender white hair +๐Ÿ‘ฉโ€๐Ÿฆฒ woman: bald adult bald woman +๐Ÿง‘โ€๐Ÿฆฒ person: bald adult bald gender-neutral person unspecified gender +๐Ÿ‘ฑโ€โ™€๏ธ woman: blond hair blond-haired woman blonde hair woman +๐Ÿ‘ฑโ€โ™‚๏ธ man: blond hair blond blond-haired man hair man +๐Ÿง“ older person adult gender-neutral old unspecified gender +๐Ÿ‘ด old man adult man old +๐Ÿ‘ต old woman adult old woman +๐Ÿ™ person frowning frown gesture +๐Ÿ™โ€โ™‚๏ธ man frowning frowning gesture man +๐Ÿ™โ€โ™€๏ธ woman frowning frowning gesture woman +๐Ÿ™Ž person pouting gesture pouting +๐Ÿ™Žโ€โ™‚๏ธ man pouting gesture man pouting +๐Ÿ™Žโ€โ™€๏ธ woman pouting gesture pouting woman +๐Ÿ™… person gesturing no forbidden gesture hand prohibited +๐Ÿ™…โ€โ™‚๏ธ man gesturing no forbidden gesture hand man prohibited +๐Ÿ™…โ€โ™€๏ธ woman gesturing no forbidden gesture hand prohibited woman +๐Ÿ™† person gesturing ok gesture hand ok +๐Ÿ™†โ€โ™‚๏ธ man gesturing ok gesture hand man ok +๐Ÿ™†โ€โ™€๏ธ woman gesturing ok gesture hand ok woman +๐Ÿ’ person tipping hand hand help information sassy tipping +๐Ÿ’โ€โ™‚๏ธ man tipping hand man sassy tipping hand +๐Ÿ’โ€โ™€๏ธ woman tipping hand sassy tipping hand woman +๐Ÿ™‹ person raising hand gesture hand happy raised +๐Ÿ™‹โ€โ™‚๏ธ man raising hand gesture man raising hand +๐Ÿ™‹โ€โ™€๏ธ woman raising hand gesture raising hand woman +๐Ÿง deaf person accessibility deaf ear hear +๐Ÿงโ€โ™‚๏ธ deaf man deaf man +๐Ÿงโ€โ™€๏ธ deaf woman deaf woman +๐Ÿ™‡ person bowing apology bow gesture sorry +๐Ÿ™‡โ€โ™‚๏ธ man bowing apology bowing favor gesture man sorry +๐Ÿ™‡โ€โ™€๏ธ woman bowing apology bowing favor gesture sorry woman +๐Ÿคฆ person facepalming disbelief exasperation face palm +๐Ÿคฆโ€โ™‚๏ธ man facepalming disbelief exasperation facepalm man +๐Ÿคฆโ€โ™€๏ธ woman facepalming disbelief exasperation facepalm woman +๐Ÿคท person shrugging doubt ignorance indifference shrug +๐Ÿคทโ€โ™‚๏ธ man shrugging doubt ignorance indifference man shrug +๐Ÿคทโ€โ™€๏ธ woman shrugging doubt ignorance indifference shrug woman +๐Ÿง‘โ€โš•๏ธ health worker doctor healthcare nurse therapist +๐Ÿ‘จโ€โš•๏ธ man health worker doctor healthcare man nurse therapist +๐Ÿ‘ฉโ€โš•๏ธ woman health worker doctor healthcare nurse therapist woman +๐Ÿง‘โ€๐ŸŽ“ student graduate +๐Ÿ‘จโ€๐ŸŽ“ man student graduate man student +๐Ÿ‘ฉโ€๐ŸŽ“ woman student graduate student woman +๐Ÿง‘โ€๐Ÿซ teacher instructor professor +๐Ÿ‘จโ€๐Ÿซ man teacher instructor man professor teacher +๐Ÿ‘ฉโ€๐Ÿซ woman teacher instructor professor teacher woman +๐Ÿง‘โ€โš–๏ธ judge justice scales +๐Ÿ‘จโ€โš–๏ธ man judge judge justice man scales +๐Ÿ‘ฉโ€โš–๏ธ woman judge judge justice scales woman +๐Ÿง‘โ€๐ŸŒพ farmer gardener rancher +๐Ÿ‘จโ€๐ŸŒพ man farmer farmer gardener man rancher +๐Ÿ‘ฉโ€๐ŸŒพ woman farmer farmer gardener rancher woman +๐Ÿง‘โ€๐Ÿณ cook chef +๐Ÿ‘จโ€๐Ÿณ man cook chef cook man +๐Ÿ‘ฉโ€๐Ÿณ woman cook chef cook woman +๐Ÿง‘โ€๐Ÿ”ง mechanic electrician plumber tradesperson +๐Ÿ‘จโ€๐Ÿ”ง man mechanic electrician man mechanic plumber tradesperson +๐Ÿ‘ฉโ€๐Ÿ”ง woman mechanic electrician mechanic plumber tradesperson woman +๐Ÿง‘โ€๐Ÿญ factory worker assembly factory industrial worker +๐Ÿ‘จโ€๐Ÿญ man factory worker assembly factory industrial man worker +๐Ÿ‘ฉโ€๐Ÿญ woman factory worker assembly factory industrial woman worker +๐Ÿง‘โ€๐Ÿ’ผ office worker architect business manager white-collar +๐Ÿ‘จโ€๐Ÿ’ผ man office worker architect business man manager white-collar +๐Ÿ‘ฉโ€๐Ÿ’ผ woman office worker architect business manager white-collar woman +๐Ÿง‘โ€๐Ÿ”ฌ scientist biologist chemist engineer physicist +๐Ÿ‘จโ€๐Ÿ”ฌ man scientist biologist chemist engineer man physicist scientist +๐Ÿ‘ฉโ€๐Ÿ”ฌ woman scientist biologist chemist engineer physicist scientist woman +๐Ÿง‘โ€๐Ÿ’ป technologist coder developer inventor software +๐Ÿ‘จโ€๐Ÿ’ป man technologist coder developer inventor man software technologist +๐Ÿ‘ฉโ€๐Ÿ’ป woman technologist coder developer inventor software technologist woman +๐Ÿง‘โ€๐ŸŽค singer actor entertainer rock star +๐Ÿ‘จโ€๐ŸŽค man singer actor entertainer man rock singer star +๐Ÿ‘ฉโ€๐ŸŽค woman singer actor entertainer rock singer star woman +๐Ÿง‘โ€๐ŸŽจ artist palette +๐Ÿ‘จโ€๐ŸŽจ man artist artist man palette +๐Ÿ‘ฉโ€๐ŸŽจ woman artist artist palette woman +๐Ÿง‘โ€โœˆ๏ธ pilot plane +๐Ÿ‘จโ€โœˆ๏ธ man pilot man pilot plane +๐Ÿ‘ฉโ€โœˆ๏ธ woman pilot pilot plane woman +๐Ÿง‘โ€๐Ÿš€ astronaut rocket +๐Ÿ‘จโ€๐Ÿš€ man astronaut astronaut man rocket +๐Ÿ‘ฉโ€๐Ÿš€ woman astronaut astronaut rocket woman +๐Ÿง‘โ€๐Ÿš’ firefighter firetruck +๐Ÿ‘จโ€๐Ÿš’ man firefighter firefighter firetruck man +๐Ÿ‘ฉโ€๐Ÿš’ woman firefighter firefighter firetruck woman +๐Ÿ‘ฎ police officer cop officer police +๐Ÿ‘ฎโ€โ™‚๏ธ man police officer cop man officer police +๐Ÿ‘ฎโ€โ™€๏ธ woman police officer cop officer police woman +๐Ÿ•ต๏ธ detective sleuth spy +๐Ÿ•ต๏ธโ€โ™‚๏ธ man detective detective man sleuth spy +๐Ÿ•ต๏ธโ€โ™€๏ธ woman detective detective sleuth spy woman +๐Ÿ’‚ guard +๐Ÿ’‚โ€โ™‚๏ธ man guard guard man +๐Ÿ’‚โ€โ™€๏ธ woman guard guard woman +๐Ÿฅท ninja fighter hidden stealth +๐Ÿ‘ท construction worker construction hat worker +๐Ÿ‘ทโ€โ™‚๏ธ man construction worker construction man worker +๐Ÿ‘ทโ€โ™€๏ธ woman construction worker construction woman worker +๐Ÿซ… person with crown monarch noble regal royalty +๐Ÿคด prince +๐Ÿ‘ธ princess fairy tale fantasy +๐Ÿ‘ณ person wearing turban turban +๐Ÿ‘ณโ€โ™‚๏ธ man wearing turban man turban +๐Ÿ‘ณโ€โ™€๏ธ woman wearing turban turban woman +๐Ÿ‘ฒ person with skullcap cap gua pi mao hat person skullcap +๐Ÿง• woman with headscarf headscarf hijab mantilla tichel +๐Ÿคต person in tuxedo groom person tuxedo +๐Ÿคตโ€โ™‚๏ธ man in tuxedo man tuxedo +๐Ÿคตโ€โ™€๏ธ woman in tuxedo tuxedo woman +๐Ÿ‘ฐ person with veil bride person veil wedding +๐Ÿ‘ฐโ€โ™‚๏ธ man with veil man veil +๐Ÿ‘ฐโ€โ™€๏ธ woman with veil veil woman +๐Ÿคฐ pregnant woman pregnant woman +๐Ÿซƒ pregnant man belly bloated full pregnant +๐Ÿซ„ pregnant person belly bloated full pregnant +๐Ÿคฑ breast-feeding baby breast nursing +๐Ÿ‘ฉโ€๐Ÿผ woman feeding baby baby feeding nursing woman +๐Ÿ‘จโ€๐Ÿผ man feeding baby baby feeding man nursing +๐Ÿง‘โ€๐Ÿผ person feeding baby baby feeding nursing person +๐Ÿ‘ผ baby angel angel baby face fairy tale fantasy +๐ŸŽ… santa claus celebration christmas claus father santa +๐Ÿคถ mrs. claus celebration christmas claus mother mrs. +๐Ÿง‘โ€๐ŸŽ„ mx claus claus, christmas +๐Ÿฆธ superhero good hero heroine superpower +๐Ÿฆธโ€โ™‚๏ธ man superhero good hero man superpower +๐Ÿฆธโ€โ™€๏ธ woman superhero good hero heroine superpower woman +๐Ÿฆน supervillain criminal evil superpower villain +๐Ÿฆนโ€โ™‚๏ธ man supervillain criminal evil man superpower villain +๐Ÿฆนโ€โ™€๏ธ woman supervillain criminal evil superpower villain woman +๐Ÿง™ mage sorcerer sorceress witch wizard +๐Ÿง™โ€โ™‚๏ธ man mage sorcerer wizard +๐Ÿง™โ€โ™€๏ธ woman mage sorceress witch +๐Ÿงš fairy oberon puck titania +๐Ÿงšโ€โ™‚๏ธ man fairy oberon puck +๐Ÿงšโ€โ™€๏ธ woman fairy titania +๐Ÿง› vampire dracula undead +๐Ÿง›โ€โ™‚๏ธ man vampire dracula undead +๐Ÿง›โ€โ™€๏ธ woman vampire undead +๐Ÿงœ merperson mermaid merman merwoman +๐Ÿงœโ€โ™‚๏ธ merman triton +๐Ÿงœโ€โ™€๏ธ mermaid merwoman +๐Ÿง elf magical +๐Ÿงโ€โ™‚๏ธ man elf magical +๐Ÿงโ€โ™€๏ธ woman elf magical +๐Ÿงž genie djinn +๐Ÿงžโ€โ™‚๏ธ man genie djinn +๐Ÿงžโ€โ™€๏ธ woman genie djinn +๐ŸงŸ zombie undead walking dead +๐ŸงŸโ€โ™‚๏ธ man zombie undead walking dead +๐ŸงŸโ€โ™€๏ธ woman zombie undead walking dead +๐ŸงŒ troll fairy tale fantasy monster +๐Ÿ’† person getting massage face massage salon +๐Ÿ’†โ€โ™‚๏ธ man getting massage face man massage +๐Ÿ’†โ€โ™€๏ธ woman getting massage face massage woman +๐Ÿ’‡ person getting haircut barber beauty haircut parlor +๐Ÿ’‡โ€โ™‚๏ธ man getting haircut haircut man +๐Ÿ’‡โ€โ™€๏ธ woman getting haircut haircut woman +๐Ÿšถ person walking hike walk walking +๐Ÿšถโ€โ™‚๏ธ man walking hike man walk +๐Ÿšถโ€โ™€๏ธ woman walking hike walk woman +๐Ÿง person standing stand standing +๐Ÿงโ€โ™‚๏ธ man standing man standing +๐Ÿงโ€โ™€๏ธ woman standing standing woman +๐ŸงŽ person kneeling kneel kneeling +๐ŸงŽโ€โ™‚๏ธ man kneeling kneeling man +๐ŸงŽโ€โ™€๏ธ woman kneeling kneeling woman +๐Ÿง‘โ€๐Ÿฆฏ person with white cane accessibility blind +๐Ÿ‘จโ€๐Ÿฆฏ man with white cane accessibility blind man +๐Ÿ‘ฉโ€๐Ÿฆฏ woman with white cane accessibility blind woman +๐Ÿง‘โ€๐Ÿฆผ person in motorized wheelchair accessibility wheelchair +๐Ÿ‘จโ€๐Ÿฆผ man in motorized wheelchair accessibility man wheelchair +๐Ÿ‘ฉโ€๐Ÿฆผ woman in motorized wheelchair accessibility wheelchair woman +๐Ÿง‘โ€๐Ÿฆฝ person in manual wheelchair accessibility wheelchair +๐Ÿ‘จโ€๐Ÿฆฝ man in manual wheelchair accessibility man wheelchair +๐Ÿ‘ฉโ€๐Ÿฆฝ woman in manual wheelchair accessibility wheelchair woman +๐Ÿƒ person running marathon running +๐Ÿƒโ€โ™‚๏ธ man running man marathon racing running +๐Ÿƒโ€โ™€๏ธ woman running marathon racing running woman +๐Ÿ’ƒ woman dancing dance dancing woman +๐Ÿ•บ man dancing dance dancing man +๐Ÿ•ด๏ธ person in suit levitating business person suit +๐Ÿ‘ฏ people with bunny ears bunny ear dancer partying +๐Ÿ‘ฏโ€โ™‚๏ธ men with bunny ears bunny ear dancer men partying +๐Ÿ‘ฏโ€โ™€๏ธ women with bunny ears bunny ear dancer partying women +๐Ÿง– person in steamy room sauna steam room +๐Ÿง–โ€โ™‚๏ธ man in steamy room sauna steam room +๐Ÿง–โ€โ™€๏ธ woman in steamy room sauna steam room +๐Ÿง— person climbing climber +๐Ÿง—โ€โ™‚๏ธ man climbing climber +๐Ÿง—โ€โ™€๏ธ woman climbing climber +๐Ÿคบ person fencing fencer fencing sword +๐Ÿ‡ horse racing horse jockey racehorse racing +โ›ท๏ธ skier ski snow +๐Ÿ‚ snowboarder ski snow snowboard +๐ŸŒ๏ธ person golfing ball golf +๐ŸŒ๏ธโ€โ™‚๏ธ man golfing golf man +๐ŸŒ๏ธโ€โ™€๏ธ woman golfing golf woman +๐Ÿ„ person surfing surfing +๐Ÿ„โ€โ™‚๏ธ man surfing man surfing +๐Ÿ„โ€โ™€๏ธ woman surfing surfing woman +๐Ÿšฃ person rowing boat boat rowboat +๐Ÿšฃโ€โ™‚๏ธ man rowing boat boat man rowboat +๐Ÿšฃโ€โ™€๏ธ woman rowing boat boat rowboat woman +๐ŸŠ person swimming swim +๐ŸŠโ€โ™‚๏ธ man swimming man swim +๐ŸŠโ€โ™€๏ธ woman swimming swim woman +โ›น๏ธ person bouncing ball ball +โ›น๏ธโ€โ™‚๏ธ man bouncing ball ball man +โ›น๏ธโ€โ™€๏ธ woman bouncing ball ball woman +๐Ÿ‹๏ธ person lifting weights lifter weight +๐Ÿ‹๏ธโ€โ™‚๏ธ man lifting weights man weight lifter +๐Ÿ‹๏ธโ€โ™€๏ธ woman lifting weights weight lifter woman +๐Ÿšด person biking bicycle biking cyclist +๐Ÿšดโ€โ™‚๏ธ man biking bicycle biking cyclist man +๐Ÿšดโ€โ™€๏ธ woman biking bicycle biking cyclist woman +๐Ÿšต person mountain biking bicycle bicyclist bike cyclist mountain +๐Ÿšตโ€โ™‚๏ธ man mountain biking bicycle bike cyclist man mountain +๐Ÿšตโ€โ™€๏ธ woman mountain biking bicycle bike biking cyclist mountain woman +๐Ÿคธ person cartwheeling cartwheel gymnastics +๐Ÿคธโ€โ™‚๏ธ man cartwheeling cartwheel gymnastics man +๐Ÿคธโ€โ™€๏ธ woman cartwheeling cartwheel gymnastics woman +๐Ÿคผ people wrestling wrestle wrestler +๐Ÿคผโ€โ™‚๏ธ men wrestling men wrestle +๐Ÿคผโ€โ™€๏ธ women wrestling women wrestle +๐Ÿคฝ person playing water polo polo water +๐Ÿคฝโ€โ™‚๏ธ man playing water polo man water polo +๐Ÿคฝโ€โ™€๏ธ woman playing water polo water polo woman +๐Ÿคพ person playing handball ball handball +๐Ÿคพโ€โ™‚๏ธ man playing handball handball man +๐Ÿคพโ€โ™€๏ธ woman playing handball handball woman +๐Ÿคน person juggling balance juggle multitask skill +๐Ÿคนโ€โ™‚๏ธ man juggling juggling man multitask +๐Ÿคนโ€โ™€๏ธ woman juggling juggling multitask woman +๐Ÿง˜ person in lotus position meditation yoga +๐Ÿง˜โ€โ™‚๏ธ man in lotus position meditation yoga +๐Ÿง˜โ€โ™€๏ธ woman in lotus position meditation yoga +๐Ÿ›€ person taking bath bath bathtub +๐Ÿ›Œ person in bed good night hotel sleep +๐Ÿง‘โ€๐Ÿคโ€๐Ÿง‘ people holding hands couple hand hold holding hands person +๐Ÿ‘ญ women holding hands couple hand holding hands women +๐Ÿ‘ซ woman and man holding hands couple hand hold holding hands man woman +๐Ÿ‘ฌ men holding hands couple gemini holding hands man men twins zodiac +๐Ÿ’ kiss couple +๐Ÿ‘ฉโ€โค๏ธโ€๐Ÿ’‹โ€๐Ÿ‘จ kiss: woman, man couple kiss man woman +๐Ÿ‘จโ€โค๏ธโ€๐Ÿ’‹โ€๐Ÿ‘จ kiss: man, man couple kiss man +๐Ÿ‘ฉโ€โค๏ธโ€๐Ÿ’‹โ€๐Ÿ‘ฉ kiss: woman, woman couple kiss woman +๐Ÿ’‘ couple with heart couple love +๐Ÿ‘ฉโ€โค๏ธโ€๐Ÿ‘จ couple with heart: woman, man couple couple with heart love man woman +๐Ÿ‘จโ€โค๏ธโ€๐Ÿ‘จ couple with heart: man, man couple couple with heart love man +๐Ÿ‘ฉโ€โค๏ธโ€๐Ÿ‘ฉ couple with heart: woman, woman couple couple with heart love woman +๐Ÿ‘ช family +๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘ฆ family: man, woman, boy boy family man woman +๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘ง family: man, woman, girl family girl man woman +๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ฆ family: man, woman, girl, boy boy family girl man woman +๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘ฆโ€๐Ÿ‘ฆ family: man, woman, boy, boy boy family man woman +๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ง family: man, woman, girl, girl family girl man woman +๐Ÿ‘จโ€๐Ÿ‘จโ€๐Ÿ‘ฆ family: man, man, boy boy family man +๐Ÿ‘จโ€๐Ÿ‘จโ€๐Ÿ‘ง family: man, man, girl family girl man +๐Ÿ‘จโ€๐Ÿ‘จโ€๐Ÿ‘งโ€๐Ÿ‘ฆ family: man, man, girl, boy boy family girl man +๐Ÿ‘จโ€๐Ÿ‘จโ€๐Ÿ‘ฆโ€๐Ÿ‘ฆ family: man, man, boy, boy boy family man +๐Ÿ‘จโ€๐Ÿ‘จโ€๐Ÿ‘งโ€๐Ÿ‘ง family: man, man, girl, girl family girl man +๐Ÿ‘ฉโ€๐Ÿ‘ฉโ€๐Ÿ‘ฆ family: woman, woman, boy boy family woman +๐Ÿ‘ฉโ€๐Ÿ‘ฉโ€๐Ÿ‘ง family: woman, woman, girl family girl woman +๐Ÿ‘ฉโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ฆ family: woman, woman, girl, boy boy family girl woman +๐Ÿ‘ฉโ€๐Ÿ‘ฉโ€๐Ÿ‘ฆโ€๐Ÿ‘ฆ family: woman, woman, boy, boy boy family woman +๐Ÿ‘ฉโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ง family: woman, woman, girl, girl family girl woman +๐Ÿ‘จโ€๐Ÿ‘ฆ family: man, boy boy family man +๐Ÿ‘จโ€๐Ÿ‘ฆโ€๐Ÿ‘ฆ family: man, boy, boy boy family man +๐Ÿ‘จโ€๐Ÿ‘ง family: man, girl family girl man +๐Ÿ‘จโ€๐Ÿ‘งโ€๐Ÿ‘ฆ family: man, girl, boy boy family girl man +๐Ÿ‘จโ€๐Ÿ‘งโ€๐Ÿ‘ง family: man, girl, girl family girl man +๐Ÿ‘ฉโ€๐Ÿ‘ฆ family: woman, boy boy family woman +๐Ÿ‘ฉโ€๐Ÿ‘ฆโ€๐Ÿ‘ฆ family: woman, boy, boy boy family woman +๐Ÿ‘ฉโ€๐Ÿ‘ง family: woman, girl family girl woman +๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ฆ family: woman, girl, boy boy family girl woman +๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ง family: woman, girl, girl family girl woman +๐Ÿ—ฃ๏ธ speaking head face head silhouette speak speaking +๐Ÿ‘ค bust in silhouette bust silhouette +๐Ÿ‘ฅ busts in silhouette bust silhouette +๐Ÿซ‚ people hugging goodbye hello hug thanks +๐Ÿ‘ฃ footprints clothing footprint print +#Animals & Nature +๐Ÿต monkey face face monkey +๐Ÿ’ monkey +๐Ÿฆ gorilla +๐Ÿฆง orangutan ape +๐Ÿถ dog face dog face pet +๐Ÿ• dog pet +๐Ÿฆฎ guide dog accessibility blind guide +๐Ÿ•โ€๐Ÿฆบ service dog accessibility assistance dog service +๐Ÿฉ poodle dog +๐Ÿบ wolf face +๐ŸฆŠ fox face +๐Ÿฆ raccoon curious sly +๐Ÿฑ cat face cat face pet +๐Ÿˆ cat pet +๐Ÿˆโ€โฌ› black cat black cat unlucky +๐Ÿฆ lion face leo zodiac +๐Ÿฏ tiger face face tiger +๐Ÿ… tiger +๐Ÿ† leopard +๐Ÿด horse face face horse +๐ŸซŽ moose animal antlers elk mammal +๐Ÿซ donkey animal ass burro mammal mule stubborn +๐ŸŽ horse equestrian racehorse racing +๐Ÿฆ„ unicorn face +๐Ÿฆ“ zebra stripe +๐ŸฆŒ deer +๐Ÿฆฌ bison buffalo herd wisent +๐Ÿฎ cow face cow face +๐Ÿ‚ ox bull taurus zodiac +๐Ÿƒ water buffalo buffalo water +๐Ÿ„ cow +๐Ÿท pig face face pig +๐Ÿ– pig sow +๐Ÿ— boar pig +๐Ÿฝ pig nose face nose pig +๐Ÿ ram aries male sheep zodiac +๐Ÿ‘ ewe female sheep +๐Ÿ goat capricorn zodiac +๐Ÿช camel dromedary hump +๐Ÿซ two-hump camel bactrian camel hump +๐Ÿฆ™ llama alpaca guanaco vicuรฑa wool +๐Ÿฆ’ giraffe spots +๐Ÿ˜ elephant +๐Ÿฆฃ mammoth extinction large tusk woolly +๐Ÿฆ rhinoceros +๐Ÿฆ› hippopotamus hippo +๐Ÿญ mouse face face mouse +๐Ÿ mouse +๐Ÿ€ rat +๐Ÿน hamster face pet +๐Ÿฐ rabbit face bunny face pet rabbit +๐Ÿ‡ rabbit bunny pet +๐Ÿฟ๏ธ chipmunk squirrel +๐Ÿฆซ beaver dam +๐Ÿฆ” hedgehog spiny +๐Ÿฆ‡ bat vampire +๐Ÿป bear face +๐Ÿปโ€โ„๏ธ polar bear arctic bear white +๐Ÿจ koala face marsupial +๐Ÿผ panda face +๐Ÿฆฅ sloth lazy slow +๐Ÿฆฆ otter fishing playful +๐Ÿฆจ skunk stink +๐Ÿฆ˜ kangaroo joey jump marsupial +๐Ÿฆก badger honey badger pester +๐Ÿพ paw prints feet paw print +๐Ÿฆƒ turkey bird +๐Ÿ” chicken bird +๐Ÿ“ rooster bird +๐Ÿฃ hatching chick baby bird chick hatching +๐Ÿค baby chick baby bird chick +๐Ÿฅ front-facing baby chick baby bird chick +๐Ÿฆ bird +๐Ÿง penguin bird +๐Ÿ•Š๏ธ dove bird fly peace +๐Ÿฆ… eagle bird +๐Ÿฆ† duck bird +๐Ÿฆข swan bird cygnet ugly duckling +๐Ÿฆ‰ owl bird wise +๐Ÿฆค dodo extinction large mauritius +๐Ÿชถ feather bird flight light plumage +๐Ÿฆฉ flamingo flamboyant tropical +๐Ÿฆš peacock bird ostentatious peahen proud +๐Ÿฆœ parrot bird pirate talk +๐Ÿชฝ wing angelic aviation bird flying mythology +๐Ÿฆโ€โฌ› black bird bird black crow raven rook +๐Ÿชฟ goose bird fowl honk silly +๐Ÿธ frog face +๐ŸŠ crocodile +๐Ÿข turtle terrapin tortoise +๐ŸฆŽ lizard reptile +๐Ÿ snake bearer ophiuchus serpent zodiac +๐Ÿฒ dragon face dragon face fairy tale +๐Ÿ‰ dragon fairy tale +๐Ÿฆ• sauropod brachiosaurus brontosaurus diplodocus +๐Ÿฆ– t-rex tyrannosaurus rex +๐Ÿณ spouting whale face spouting whale +๐Ÿ‹ whale +๐Ÿฌ dolphin flipper +๐Ÿฆญ seal sea lion +๐ŸŸ fish pisces zodiac +๐Ÿ  tropical fish fish tropical +๐Ÿก blowfish fish +๐Ÿฆˆ shark fish +๐Ÿ™ octopus +๐Ÿš spiral shell shell spiral +๐Ÿชธ coral ocean reef +๐Ÿชผ jellyfish burn invertebrate jelly marine ouch stinger +๐ŸŒ snail +๐Ÿฆ‹ butterfly insect pretty +๐Ÿ› bug insect +๐Ÿœ ant insect +๐Ÿ honeybee bee insect +๐Ÿชฒ beetle bug insect +๐Ÿž lady beetle beetle insect ladybird ladybug +๐Ÿฆ— cricket grasshopper +๐Ÿชณ cockroach insect pest roach +๐Ÿ•ท๏ธ spider insect +๐Ÿ•ธ๏ธ spider web spider web +๐Ÿฆ‚ scorpion scorpio zodiac +๐ŸฆŸ mosquito disease fever malaria pest virus +๐Ÿชฐ fly disease maggot pest rotting +๐Ÿชฑ worm annelid earthworm parasite +๐Ÿฆ  microbe amoeba bacteria virus +๐Ÿ’ bouquet flower +๐ŸŒธ cherry blossom blossom cherry flower +๐Ÿ’ฎ white flower flower +๐Ÿชท lotus buddhism flower hinduism purity +๐Ÿต๏ธ rosette plant +๐ŸŒน rose flower +๐Ÿฅ€ wilted flower flower wilted +๐ŸŒบ hibiscus flower +๐ŸŒป sunflower flower sun +๐ŸŒผ blossom flower +๐ŸŒท tulip flower +๐Ÿชป hyacinth bluebonnet flower lavender lupine snapdragon +๐ŸŒฑ seedling young +๐Ÿชด potted plant boring grow house nurturing plant useless +๐ŸŒฒ evergreen tree tree +๐ŸŒณ deciduous tree deciduous shedding tree +๐ŸŒด palm tree palm tree +๐ŸŒต cactus plant +๐ŸŒพ sheaf of rice ear grain rice +๐ŸŒฟ herb leaf +โ˜˜๏ธ shamrock plant +๐Ÿ€ four leaf clover 4 clover four four-leaf clover leaf +๐Ÿ maple leaf falling leaf maple +๐Ÿ‚ fallen leaf falling leaf +๐Ÿƒ leaf fluttering in wind blow flutter leaf wind +๐Ÿชน empty nest nesting +๐Ÿชบ nest with eggs nesting +๐Ÿ„ mushroom toadstool +#Food & Drink +๐Ÿ‡ grapes fruit grape +๐Ÿˆ melon fruit +๐Ÿ‰ watermelon fruit +๐ŸŠ tangerine fruit orange +๐Ÿ‹ lemon citrus fruit +๐ŸŒ banana fruit +๐Ÿ pineapple fruit +๐Ÿฅญ mango fruit tropical +๐ŸŽ red apple apple fruit red +๐Ÿ green apple apple fruit green +๐Ÿ pear fruit +๐Ÿ‘ peach fruit +๐Ÿ’ cherries berries cherry fruit red +๐Ÿ“ strawberry berry fruit +๐Ÿซ blueberries berry bilberry blue blueberry +๐Ÿฅ kiwi fruit food fruit kiwi +๐Ÿ… tomato fruit vegetable +๐Ÿซ’ olive food +๐Ÿฅฅ coconut palm piรฑa colada +๐Ÿฅ‘ avocado food fruit +๐Ÿ† eggplant aubergine vegetable +๐Ÿฅ” potato food vegetable +๐Ÿฅ• carrot food vegetable +๐ŸŒฝ ear of corn corn ear maize maze +๐ŸŒถ๏ธ hot pepper hot pepper +๐Ÿซ‘ bell pepper capsicum pepper vegetable +๐Ÿฅ’ cucumber food pickle vegetable +๐Ÿฅฌ leafy green bok choy cabbage kale lettuce +๐Ÿฅฆ broccoli wild cabbage +๐Ÿง„ garlic flavoring +๐Ÿง… onion flavoring +๐Ÿฅœ peanuts food nut peanut vegetable +๐Ÿซ˜ beans food kidney legume +๐ŸŒฐ chestnut plant +๐Ÿซš ginger root beer root spice +๐Ÿซ› pea pod beans edamame legume pea pod vegetable +๐Ÿž bread loaf +๐Ÿฅ croissant bread breakfast food french roll +๐Ÿฅ– baguette bread baguette bread food french +๐Ÿซ“ flatbread arepa lavash naan pita +๐Ÿฅจ pretzel twisted +๐Ÿฅฏ bagel bakery breakfast schmear +๐Ÿฅž pancakes breakfast crรชpe food hotcake pancake +๐Ÿง‡ waffle breakfast indecisive iron +๐Ÿง€ cheese wedge cheese +๐Ÿ– meat on bone bone meat +๐Ÿ— poultry leg bone chicken drumstick leg poultry +๐Ÿฅฉ cut of meat chop lambchop porkchop steak +๐Ÿฅ“ bacon breakfast food meat +๐Ÿ” hamburger burger +๐ŸŸ french fries french fries +๐Ÿ• pizza cheese slice +๐ŸŒญ hot dog frankfurter hotdog sausage +๐Ÿฅช sandwich bread +๐ŸŒฎ taco mexican +๐ŸŒฏ burrito mexican wrap +๐Ÿซ” tamale mexican wrapped +๐Ÿฅ™ stuffed flatbread falafel flatbread food gyro kebab stuffed +๐Ÿง† falafel chickpea meatball +๐Ÿฅš egg breakfast food +๐Ÿณ cooking breakfast egg frying pan +๐Ÿฅ˜ shallow pan of food casserole food paella pan shallow +๐Ÿฒ pot of food pot stew +๐Ÿซ• fondue cheese chocolate melted pot swiss +๐Ÿฅฃ bowl with spoon breakfast cereal congee +๐Ÿฅ— green salad food green salad +๐Ÿฟ popcorn +๐Ÿงˆ butter dairy +๐Ÿง‚ salt condiment shaker +๐Ÿฅซ canned food can +๐Ÿฑ bento box bento box +๐Ÿ˜ rice cracker cracker rice +๐Ÿ™ rice ball ball japanese rice +๐Ÿš cooked rice cooked rice +๐Ÿ› curry rice curry rice +๐Ÿœ steaming bowl bowl noodle ramen steaming +๐Ÿ spaghetti pasta +๐Ÿ  roasted sweet potato potato roasted sweet +๐Ÿข oden kebab seafood skewer stick +๐Ÿฃ sushi +๐Ÿค fried shrimp fried prawn shrimp tempura +๐Ÿฅ fish cake with swirl cake fish pastry swirl +๐Ÿฅฎ moon cake autumn festival yuรจbวng +๐Ÿก dango dessert japanese skewer stick sweet +๐ŸฅŸ dumpling empanada gyลza jiaozi pierogi potsticker +๐Ÿฅ  fortune cookie prophecy +๐Ÿฅก takeout box oyster pail +๐Ÿฆ€ crab cancer zodiac +๐Ÿฆž lobster bisque claws seafood +๐Ÿฆ shrimp food shellfish small +๐Ÿฆ‘ squid food molusc +๐Ÿฆช oyster diving pearl +๐Ÿฆ soft ice cream cream dessert ice icecream soft sweet +๐Ÿง shaved ice dessert ice shaved sweet +๐Ÿจ ice cream cream dessert ice sweet +๐Ÿฉ doughnut breakfast dessert donut sweet +๐Ÿช cookie dessert sweet +๐ŸŽ‚ birthday cake birthday cake celebration dessert pastry sweet +๐Ÿฐ shortcake cake dessert pastry slice sweet +๐Ÿง cupcake bakery sweet +๐Ÿฅง pie filling pastry +๐Ÿซ chocolate bar bar chocolate dessert sweet +๐Ÿฌ candy dessert sweet +๐Ÿญ lollipop candy dessert sweet +๐Ÿฎ custard dessert pudding sweet +๐Ÿฏ honey pot honey honeypot pot sweet +๐Ÿผ baby bottle baby bottle drink milk +๐Ÿฅ› glass of milk drink glass milk +โ˜• hot beverage beverage coffee drink hot steaming tea +๐Ÿซ– teapot drink pot tea +๐Ÿต teacup without handle beverage cup drink tea teacup +๐Ÿถ sake bar beverage bottle cup drink +๐Ÿพ bottle with popping cork bar bottle cork drink popping +๐Ÿท wine glass bar beverage drink glass wine +๐Ÿธ cocktail glass bar cocktail drink glass +๐Ÿน tropical drink bar drink tropical +๐Ÿบ beer mug bar beer drink mug +๐Ÿป clinking beer mugs bar beer clink drink mug +๐Ÿฅ‚ clinking glasses celebrate clink drink glass +๐Ÿฅƒ tumbler glass glass liquor shot tumbler whisky +๐Ÿซ— pouring liquid drink empty glass spill +๐Ÿฅค cup with straw juice soda +๐Ÿง‹ bubble tea bubble milk pearl tea +๐Ÿงƒ beverage box beverage box juice straw sweet +๐Ÿง‰ mate drink +๐ŸงŠ ice cold ice cube iceberg +๐Ÿฅข chopsticks hashi +๐Ÿฝ๏ธ fork and knife with plate cooking fork knife plate +๐Ÿด fork and knife cooking cutlery fork knife +๐Ÿฅ„ spoon tableware +๐Ÿ”ช kitchen knife cooking hocho knife tool weapon +๐Ÿซ™ jar condiment container empty sauce store +๐Ÿบ amphora aquarius cooking drink jug zodiac +#Activities +๐ŸŽƒ jack-o-lantern celebration halloween jack lantern +๐ŸŽ„ christmas tree celebration christmas tree +๐ŸŽ† fireworks celebration +๐ŸŽ‡ sparkler celebration fireworks sparkle +๐Ÿงจ firecracker dynamite explosive fireworks +โœจ sparkles * sparkle star +๐ŸŽˆ balloon celebration +๐ŸŽ‰ party popper celebration party popper tada +๐ŸŽŠ confetti ball ball celebration confetti +๐ŸŽ‹ tanabata tree banner celebration japanese tree +๐ŸŽ pine decoration bamboo celebration japanese pine +๐ŸŽŽ japanese dolls celebration doll festival japanese +๐ŸŽ carp streamer carp celebration streamer +๐ŸŽ wind chime bell celebration chime wind +๐ŸŽ‘ moon viewing ceremony celebration ceremony moon +๐Ÿงง red envelope gift good luck hรณngbฤo lai see money +๐ŸŽ€ ribbon celebration +๐ŸŽ wrapped gift box celebration gift present wrapped +๐ŸŽ—๏ธ reminder ribbon celebration reminder ribbon +๐ŸŽŸ๏ธ admission tickets admission ticket +๐ŸŽซ ticket admission +๐ŸŽ–๏ธ military medal celebration medal military +๐Ÿ† trophy prize +๐Ÿ… sports medal medal +๐Ÿฅ‡ 1st place medal first gold medal +๐Ÿฅˆ 2nd place medal medal second silver +๐Ÿฅ‰ 3rd place medal bronze medal third +โšฝ soccer ball ball football soccer +โšพ baseball ball +๐ŸฅŽ softball ball glove underarm +๐Ÿ€ basketball ball hoop +๐Ÿ volleyball ball game +๐Ÿˆ american football american ball football +๐Ÿ‰ rugby football ball football rugby +๐ŸŽพ tennis ball racquet +๐Ÿฅ flying disc ultimate +๐ŸŽณ bowling ball game +๐Ÿ cricket game ball bat game +๐Ÿ‘ field hockey ball field game hockey stick +๐Ÿ’ ice hockey game hockey ice puck stick +๐Ÿฅ lacrosse ball goal stick +๐Ÿ“ ping pong ball bat game paddle table tennis +๐Ÿธ badminton birdie game racquet shuttlecock +๐ŸฅŠ boxing glove boxing glove +๐Ÿฅ‹ martial arts uniform judo karate martial arts taekwondo uniform +๐Ÿฅ… goal net goal net +โ›ณ flag in hole golf hole +โ›ธ๏ธ ice skate ice skate +๐ŸŽฃ fishing pole fish pole +๐Ÿคฟ diving mask diving scuba snorkeling +๐ŸŽฝ running shirt athletics running sash shirt +๐ŸŽฟ skis ski snow +๐Ÿ›ท sled sledge sleigh +๐ŸฅŒ curling stone game rock +๐ŸŽฏ bullseye dart direct hit game hit target +๐Ÿช€ yo-yo fluctuate toy +๐Ÿช kite fly soar +๐Ÿ”ซ water pistol gun handgun pistol revolver tool water weapon +๐ŸŽฑ pool 8 ball 8 ball billiard eight game +๐Ÿ”ฎ crystal ball ball crystal fairy tale fantasy fortune tool +๐Ÿช„ magic wand magic witch wizard +๐ŸŽฎ video game controller game +๐Ÿ•น๏ธ joystick game video game +๐ŸŽฐ slot machine game slot +๐ŸŽฒ game die dice die game +๐Ÿงฉ puzzle piece clue interlocking jigsaw piece puzzle +๐Ÿงธ teddy bear plaything plush stuffed toy +๐Ÿช… piรฑata celebration party +๐Ÿชฉ mirror ball dance disco glitter party +๐Ÿช† nesting dolls doll nesting russia +โ™ ๏ธ spade suit card game +โ™ฅ๏ธ heart suit card game +โ™ฆ๏ธ diamond suit card game +โ™ฃ๏ธ club suit card game +โ™Ÿ๏ธ chess pawn chess dupe expendable +๐ŸŽด flower playing cards card flower game japanese playing +๐ŸŽญ performing arts art mask performing theater theatre +๐Ÿ–ผ๏ธ framed picture art frame museum painting picture +๐ŸŽจ artist palette art museum painting palette +๐Ÿงต thread needle sewing spool string +๐Ÿชก sewing needle embroidery needle sewing stitches sutures tailoring +๐Ÿงถ yarn ball crochet knit +๐Ÿชข knot rope tangled tie twine twist +#Travel & Places +๐ŸŒ globe showing europe-africa africa earth europe globe world +๐ŸŒŽ globe showing americas americas earth globe world +๐ŸŒ globe showing asia-australia asia australia earth globe world +๐ŸŒ globe with meridians earth globe meridians world +๐Ÿ—บ๏ธ world map map world +๐Ÿ—พ map of japan japan map +๐Ÿงญ compass magnetic navigation orienteering +๐Ÿ”๏ธ snow-capped mountain cold mountain snow +โ›ฐ๏ธ mountain +๐ŸŒ‹ volcano eruption mountain +๐Ÿ—ป mount fuji fuji mountain +๐Ÿ•๏ธ camping +๐Ÿ–๏ธ beach with umbrella beach umbrella +๐Ÿœ๏ธ desert +๐Ÿ๏ธ desert island desert island +๐Ÿž๏ธ national park park +๐ŸŸ๏ธ stadium +๐Ÿ›๏ธ classical building classical +๐Ÿ—๏ธ building construction construction +๐Ÿงฑ brick bricks clay mortar wall +๐Ÿชจ rock boulder heavy solid stone +๐Ÿชต wood log lumber timber +๐Ÿ›– hut house roundhouse yurt +๐Ÿ˜๏ธ houses +๐Ÿš๏ธ derelict house derelict house +๐Ÿ  house home +๐Ÿก house with garden garden home house +๐Ÿข office building building +๐Ÿฃ japanese post office japanese post +๐Ÿค post office european post +๐Ÿฅ hospital doctor medicine +๐Ÿฆ bank building +๐Ÿจ hotel building +๐Ÿฉ love hotel hotel love +๐Ÿช convenience store convenience store +๐Ÿซ school building +๐Ÿฌ department store department store +๐Ÿญ factory building +๐Ÿฏ japanese castle castle japanese +๐Ÿฐ castle european +๐Ÿ’’ wedding chapel romance +๐Ÿ—ผ tokyo tower tokyo tower +๐Ÿ—ฝ statue of liberty liberty statue +โ›ช church christian cross religion +๐Ÿ•Œ mosque islam muslim religion +๐Ÿ›• hindu temple hindu temple +๐Ÿ• synagogue jew jewish religion temple +โ›ฉ๏ธ shinto shrine religion shinto shrine +๐Ÿ•‹ kaaba islam muslim religion +โ›ฒ fountain +โ›บ tent camping +๐ŸŒ foggy fog +๐ŸŒƒ night with stars night star +๐Ÿ™๏ธ cityscape city +๐ŸŒ„ sunrise over mountains morning mountain sun sunrise +๐ŸŒ… sunrise morning sun +๐ŸŒ† cityscape at dusk city dusk evening landscape sunset +๐ŸŒ‡ sunset dusk sun +๐ŸŒ‰ bridge at night bridge night +โ™จ๏ธ hot springs hot hotsprings springs steaming +๐ŸŽ  carousel horse carousel horse +๐Ÿ› playground slide amusement park play +๐ŸŽก ferris wheel amusement park ferris wheel +๐ŸŽข roller coaster amusement park coaster roller +๐Ÿ’ˆ barber pole barber haircut pole +๐ŸŽช circus tent circus tent +๐Ÿš‚ locomotive engine railway steam train +๐Ÿšƒ railway car car electric railway train tram trolleybus +๐Ÿš„ high-speed train railway shinkansen speed train +๐Ÿš… bullet train bullet railway shinkansen speed train +๐Ÿš† train railway +๐Ÿš‡ metro subway +๐Ÿšˆ light rail railway +๐Ÿš‰ station railway train +๐ŸšŠ tram trolleybus +๐Ÿš monorail vehicle +๐Ÿšž mountain railway car mountain railway +๐Ÿš‹ tram car car tram trolleybus +๐ŸšŒ bus vehicle +๐Ÿš oncoming bus bus oncoming +๐ŸšŽ trolleybus bus tram trolley +๐Ÿš minibus bus +๐Ÿš‘ ambulance vehicle +๐Ÿš’ fire engine engine fire truck +๐Ÿš“ police car car patrol police +๐Ÿš” oncoming police car car oncoming police +๐Ÿš• taxi vehicle +๐Ÿš– oncoming taxi oncoming taxi +๐Ÿš— automobile car +๐Ÿš˜ oncoming automobile automobile car oncoming +๐Ÿš™ sport utility vehicle recreational sport utility +๐Ÿ›ป pickup truck pick-up pickup truck +๐Ÿšš delivery truck delivery truck +๐Ÿš› articulated lorry lorry semi truck +๐Ÿšœ tractor vehicle +๐ŸŽ๏ธ racing car car racing +๐Ÿ๏ธ motorcycle racing +๐Ÿ›ต motor scooter motor scooter +๐Ÿฆฝ manual wheelchair accessibility +๐Ÿฆผ motorized wheelchair accessibility +๐Ÿ›บ auto rickshaw tuk tuk +๐Ÿšฒ bicycle bike +๐Ÿ›ด kick scooter kick scooter +๐Ÿ›น skateboard board +๐Ÿ›ผ roller skate roller skate +๐Ÿš bus stop bus stop +๐Ÿ›ฃ๏ธ motorway highway road +๐Ÿ›ค๏ธ railway track railway train +๐Ÿ›ข๏ธ oil drum drum oil +โ›ฝ fuel pump diesel fuel fuelpump gas pump station +๐Ÿ›ž wheel circle tire turn +๐Ÿšจ police car light beacon car light police revolving +๐Ÿšฅ horizontal traffic light light signal traffic +๐Ÿšฆ vertical traffic light light signal traffic +๐Ÿ›‘ stop sign octagonal sign stop +๐Ÿšง construction barrier +โš“ anchor ship tool +๐Ÿ›Ÿ ring buoy float life preserver life saver rescue safety +โ›ต sailboat boat resort sea yacht +๐Ÿ›ถ canoe boat +๐Ÿšค speedboat boat +๐Ÿ›ณ๏ธ passenger ship passenger ship +โ›ด๏ธ ferry boat passenger +๐Ÿ›ฅ๏ธ motor boat boat motorboat +๐Ÿšข ship boat passenger +โœˆ๏ธ airplane aeroplane +๐Ÿ›ฉ๏ธ small airplane aeroplane airplane +๐Ÿ›ซ airplane departure aeroplane airplane check-in departure departures +๐Ÿ›ฌ airplane arrival aeroplane airplane arrivals arriving landing +๐Ÿช‚ parachute hang-glide parasail skydive +๐Ÿ’บ seat chair +๐Ÿš helicopter vehicle +๐ŸšŸ suspension railway railway suspension +๐Ÿš  mountain cableway cable gondola mountain +๐Ÿšก aerial tramway aerial cable car gondola tramway +๐Ÿ›ฐ๏ธ satellite space +๐Ÿš€ rocket space +๐Ÿ›ธ flying saucer ufo +๐Ÿ›Ž๏ธ bellhop bell bell bellhop hotel +๐Ÿงณ luggage packing travel +โŒ› hourglass done sand timer +โณ hourglass not done hourglass sand timer +โŒš watch clock +โฐ alarm clock alarm clock +โฑ๏ธ stopwatch clock +โฒ๏ธ timer clock clock timer +๐Ÿ•ฐ๏ธ mantelpiece clock clock +๐Ÿ•› twelve oโ€™clock 00 12 12:00 clock oโ€™clock twelve +๐Ÿ•ง twelve-thirty 12 12:30 clock thirty twelve +๐Ÿ• one oโ€™clock 00 1 1:00 clock oโ€™clock one +๐Ÿ•œ one-thirty 1 1:30 clock one thirty +๐Ÿ•‘ two oโ€™clock 00 2 2:00 clock oโ€™clock two +๐Ÿ• two-thirty 2 2:30 clock thirty two +๐Ÿ•’ three oโ€™clock 00 3 3:00 clock oโ€™clock three +๐Ÿ•ž three-thirty 3 3:30 clock thirty three +๐Ÿ•“ four oโ€™clock 00 4 4:00 clock four oโ€™clock +๐Ÿ•Ÿ four-thirty 4 4:30 clock four thirty +๐Ÿ•” five oโ€™clock 00 5 5:00 clock five oโ€™clock +๐Ÿ•  five-thirty 5 5:30 clock five thirty +๐Ÿ•• six oโ€™clock 00 6 6:00 clock oโ€™clock six +๐Ÿ•ก six-thirty 6 6:30 clock six thirty +๐Ÿ•– seven oโ€™clock 00 7 7:00 clock oโ€™clock seven +๐Ÿ•ข seven-thirty 7 7:30 clock seven thirty +๐Ÿ•— eight oโ€™clock 00 8 8:00 clock eight oโ€™clock +๐Ÿ•ฃ eight-thirty 8 8:30 clock eight thirty +๐Ÿ•˜ nine oโ€™clock 00 9 9:00 clock nine oโ€™clock +๐Ÿ•ค nine-thirty 9 9:30 clock nine thirty +๐Ÿ•™ ten oโ€™clock 00 10 10:00 clock oโ€™clock ten +๐Ÿ•ฅ ten-thirty 10 10:30 clock ten thirty +๐Ÿ•š eleven oโ€™clock 00 11 11:00 clock eleven oโ€™clock +๐Ÿ•ฆ eleven-thirty 11 11:30 clock eleven thirty +๐ŸŒ‘ new moon dark moon +๐ŸŒ’ waxing crescent moon crescent moon waxing +๐ŸŒ“ first quarter moon moon quarter +๐ŸŒ” waxing gibbous moon gibbous moon waxing +๐ŸŒ• full moon full moon +๐ŸŒ– waning gibbous moon gibbous moon waning +๐ŸŒ— last quarter moon moon quarter +๐ŸŒ˜ waning crescent moon crescent moon waning +๐ŸŒ™ crescent moon crescent moon +๐ŸŒš new moon face face moon +๐ŸŒ› first quarter moon face face moon quarter +๐ŸŒœ last quarter moon face face moon quarter +๐ŸŒก๏ธ thermometer weather +โ˜€๏ธ sun bright rays sunny +๐ŸŒ full moon face bright face full moon +๐ŸŒž sun with face bright face sun +๐Ÿช ringed planet saturn saturnine +โญ star +๐ŸŒŸ glowing star glittery glow shining sparkle star +๐ŸŒ  shooting star falling shooting star +๐ŸŒŒ milky way space +โ˜๏ธ cloud weather +โ›… sun behind cloud cloud sun +โ›ˆ๏ธ cloud with lightning and rain cloud rain thunder +๐ŸŒค๏ธ sun behind small cloud cloud sun +๐ŸŒฅ๏ธ sun behind large cloud cloud sun +๐ŸŒฆ๏ธ sun behind rain cloud cloud rain sun +๐ŸŒง๏ธ cloud with rain cloud rain +๐ŸŒจ๏ธ cloud with snow cloud cold snow +๐ŸŒฉ๏ธ cloud with lightning cloud lightning +๐ŸŒช๏ธ tornado cloud whirlwind +๐ŸŒซ๏ธ fog cloud +๐ŸŒฌ๏ธ wind face blow cloud face wind +๐ŸŒ€ cyclone dizzy hurricane twister typhoon +๐ŸŒˆ rainbow rain +๐ŸŒ‚ closed umbrella clothing rain umbrella +โ˜‚๏ธ umbrella clothing rain +โ˜” umbrella with rain drops clothing drop rain umbrella +โ›ฑ๏ธ umbrella on ground rain sun umbrella +โšก high voltage danger electric lightning voltage zap +โ„๏ธ snowflake cold snow +โ˜ƒ๏ธ snowman cold snow +โ›„ snowman without snow cold snow snowman +โ˜„๏ธ comet space +๐Ÿ”ฅ fire flame tool +๐Ÿ’ง droplet cold comic drop sweat +๐ŸŒŠ water wave ocean water wave +#Objects +๐Ÿ‘“ glasses clothing eye eyeglasses eyewear +๐Ÿ•ถ๏ธ sunglasses dark eye eyewear glasses +๐Ÿฅฝ goggles eye protection swimming welding +๐Ÿฅผ lab coat doctor experiment scientist +๐Ÿฆบ safety vest emergency safety vest +๐Ÿ‘” necktie clothing tie +๐Ÿ‘• t-shirt clothing shirt tshirt +๐Ÿ‘– jeans clothing pants trousers +๐Ÿงฃ scarf neck +๐Ÿงค gloves hand +๐Ÿงฅ coat jacket +๐Ÿงฆ socks stocking +๐Ÿ‘— dress clothing +๐Ÿ‘˜ kimono clothing +๐Ÿฅป sari clothing dress +๐Ÿฉฑ one-piece swimsuit bathing suit +๐Ÿฉฒ briefs bathing suit one-piece swimsuit underwear +๐Ÿฉณ shorts bathing suit pants underwear +๐Ÿ‘™ bikini clothing swim +๐Ÿ‘š womanโ€™s clothes clothing woman +๐Ÿชญ folding hand fan cooling dance fan flutter hot shy +๐Ÿ‘› purse clothing coin +๐Ÿ‘œ handbag bag clothing purse +๐Ÿ‘ clutch bag bag clothing pouch +๐Ÿ›๏ธ shopping bags bag hotel shopping +๐ŸŽ’ backpack bag rucksack satchel school +๐Ÿฉด thong sandal beach sandals sandals thong sandals thongs zลri +๐Ÿ‘ž manโ€™s shoe clothing man shoe +๐Ÿ‘Ÿ running shoe athletic clothing shoe sneaker +๐Ÿฅพ hiking boot backpacking boot camping hiking +๐Ÿฅฟ flat shoe ballet flat slip-on slipper +๐Ÿ‘  high-heeled shoe clothing heel shoe woman +๐Ÿ‘ก womanโ€™s sandal clothing sandal shoe woman +๐Ÿฉฐ ballet shoes ballet dance +๐Ÿ‘ข womanโ€™s boot boot clothing shoe woman +๐Ÿชฎ hair pick afro comb hair pick +๐Ÿ‘‘ crown clothing king queen +๐Ÿ‘’ womanโ€™s hat clothing hat woman +๐ŸŽฉ top hat clothing hat top tophat +๐ŸŽ“ graduation cap cap celebration clothing graduation hat +๐Ÿงข billed cap baseball cap +๐Ÿช– military helmet army helmet military soldier warrior +โ›‘๏ธ rescue workerโ€™s helmet aid cross face hat helmet +๐Ÿ“ฟ prayer beads beads clothing necklace prayer religion +๐Ÿ’„ lipstick cosmetics makeup +๐Ÿ’ ring diamond +๐Ÿ’Ž gem stone diamond gem jewel +๐Ÿ”‡ muted speaker mute quiet silent speaker +๐Ÿ”ˆ speaker low volume soft +๐Ÿ”‰ speaker medium volume medium +๐Ÿ”Š speaker high volume loud +๐Ÿ“ข loudspeaker loud public address +๐Ÿ“ฃ megaphone cheering +๐Ÿ“ฏ postal horn horn post postal +๐Ÿ”” bell +๐Ÿ”• bell with slash bell forbidden mute quiet silent +๐ŸŽผ musical score music score +๐ŸŽต musical note music note +๐ŸŽถ musical notes music note notes +๐ŸŽ™๏ธ studio microphone mic microphone music studio +๐ŸŽš๏ธ level slider level music slider +๐ŸŽ›๏ธ control knobs control knobs music +๐ŸŽค microphone karaoke mic +๐ŸŽง headphone earbud +๐Ÿ“ป radio video +๐ŸŽท saxophone instrument music sax +๐Ÿช— accordion concertina squeeze box +๐ŸŽธ guitar instrument music +๐ŸŽน musical keyboard instrument keyboard music piano +๐ŸŽบ trumpet instrument music +๐ŸŽป violin instrument music +๐Ÿช• banjo music stringed +๐Ÿฅ drum drumsticks music +๐Ÿช˜ long drum beat conga drum rhythm +๐Ÿช‡ maracas instrument music percussion rattle shake +๐Ÿชˆ flute fife music pipe recorder woodwind +๐Ÿ“ฑ mobile phone cell mobile phone telephone +๐Ÿ“ฒ mobile phone with arrow arrow cell mobile phone receive +โ˜Ž๏ธ telephone phone +๐Ÿ“ž telephone receiver phone receiver telephone +๐Ÿ“Ÿ pager +๐Ÿ“  fax machine fax +๐Ÿ”‹ battery +๐Ÿชซ low battery electronic low energy +๐Ÿ”Œ electric plug electric electricity plug +๐Ÿ’ป laptop computer pc personal +๐Ÿ–ฅ๏ธ desktop computer computer desktop +๐Ÿ–จ๏ธ printer computer +โŒจ๏ธ keyboard computer +๐Ÿ–ฑ๏ธ computer mouse computer +๐Ÿ–ฒ๏ธ trackball computer +๐Ÿ’ฝ computer disk computer disk minidisk optical +๐Ÿ’พ floppy disk computer disk floppy +๐Ÿ’ฟ optical disk cd computer disk optical +๐Ÿ“€ dvd blu-ray computer disk optical +๐Ÿงฎ abacus calculation +๐ŸŽฅ movie camera camera cinema movie +๐ŸŽž๏ธ film frames cinema film frames movie +๐Ÿ“ฝ๏ธ film projector cinema film movie projector video +๐ŸŽฌ clapper board clapper movie +๐Ÿ“บ television tv video +๐Ÿ“ท camera video +๐Ÿ“ธ camera with flash camera flash video +๐Ÿ“น video camera camera video +๐Ÿ“ผ videocassette tape vhs video +๐Ÿ” magnifying glass tilted left glass magnifying search tool +๐Ÿ”Ž magnifying glass tilted right glass magnifying search tool +๐Ÿ•ฏ๏ธ candle light +๐Ÿ’ก light bulb bulb comic electric idea light +๐Ÿ”ฆ flashlight electric light tool torch +๐Ÿฎ red paper lantern bar lantern light red +๐Ÿช” diya lamp diya lamp oil +๐Ÿ“” notebook with decorative cover book cover decorated notebook +๐Ÿ“• closed book book closed +๐Ÿ“– open book book open +๐Ÿ“— green book book green +๐Ÿ“˜ blue book blue book +๐Ÿ“™ orange book book orange +๐Ÿ“š books book +๐Ÿ““ notebook +๐Ÿ“’ ledger notebook +๐Ÿ“ƒ page with curl curl document page +๐Ÿ“œ scroll paper +๐Ÿ“„ page facing up document page +๐Ÿ“ฐ newspaper news paper +๐Ÿ—ž๏ธ rolled-up newspaper news newspaper paper rolled +๐Ÿ“‘ bookmark tabs bookmark mark marker tabs +๐Ÿ”– bookmark mark +๐Ÿท๏ธ label +๐Ÿ’ฐ money bag bag dollar money moneybag +๐Ÿช™ coin gold metal money silver treasure +๐Ÿ’ด yen banknote banknote bill currency money note yen +๐Ÿ’ต dollar banknote banknote bill currency dollar money note +๐Ÿ’ถ euro banknote banknote bill currency euro money note +๐Ÿ’ท pound banknote banknote bill currency money note pound +๐Ÿ’ธ money with wings banknote bill fly money wings +๐Ÿ’ณ credit card card credit money +๐Ÿงพ receipt accounting bookkeeping evidence proof +๐Ÿ’น chart increasing with yen chart graph growth money yen +โœ‰๏ธ envelope email letter +๐Ÿ“ง e-mail email letter mail +๐Ÿ“จ incoming envelope e-mail email envelope incoming letter receive +๐Ÿ“ฉ envelope with arrow arrow e-mail email envelope outgoing +๐Ÿ“ค outbox tray box letter mail outbox sent tray +๐Ÿ“ฅ inbox tray box inbox letter mail receive tray +๐Ÿ“ฆ package box parcel +๐Ÿ“ซ closed mailbox with raised flag closed mail mailbox postbox +๐Ÿ“ช closed mailbox with lowered flag closed lowered mail mailbox postbox +๐Ÿ“ฌ open mailbox with raised flag mail mailbox open postbox +๐Ÿ“ญ open mailbox with lowered flag lowered mail mailbox open postbox +๐Ÿ“ฎ postbox mail mailbox +๐Ÿ—ณ๏ธ ballot box with ballot ballot box +โœ๏ธ pencil +โœ’๏ธ black nib nib pen +๐Ÿ–‹๏ธ fountain pen fountain pen +๐Ÿ–Š๏ธ pen ballpoint +๐Ÿ–Œ๏ธ paintbrush painting +๐Ÿ–๏ธ crayon +๐Ÿ“ memo pencil +๐Ÿ’ผ briefcase +๐Ÿ“ file folder file folder +๐Ÿ“‚ open file folder file folder open +๐Ÿ—‚๏ธ card index dividers card dividers index +๐Ÿ“… calendar date +๐Ÿ“† tear-off calendar calendar +๐Ÿ—’๏ธ spiral notepad note pad spiral +๐Ÿ—“๏ธ spiral calendar calendar pad spiral +๐Ÿ“‡ card index card index rolodex +๐Ÿ“ˆ chart increasing chart graph growth trend upward +๐Ÿ“‰ chart decreasing chart down graph trend +๐Ÿ“Š bar chart bar chart graph +๐Ÿ“‹ clipboard +๐Ÿ“Œ pushpin pin +๐Ÿ“ round pushpin pin pushpin +๐Ÿ“Ž paperclip +๐Ÿ–‡๏ธ linked paperclips link paperclip +๐Ÿ“ straight ruler ruler straight edge +๐Ÿ“ triangular ruler ruler set triangle +โœ‚๏ธ scissors cutting tool +๐Ÿ—ƒ๏ธ card file box box card file +๐Ÿ—„๏ธ file cabinet cabinet file filing +๐Ÿ—‘๏ธ wastebasket +๐Ÿ”’ locked closed +๐Ÿ”“ unlocked lock open unlock +๐Ÿ” locked with pen ink lock nib pen privacy +๐Ÿ” locked with key closed key lock secure +๐Ÿ”‘ key lock password +๐Ÿ—๏ธ old key clue key lock old +๐Ÿ”จ hammer tool +๐Ÿช“ axe chop hatchet split wood +โ›๏ธ pick mining tool +โš’๏ธ hammer and pick hammer pick tool +๐Ÿ› ๏ธ hammer and wrench hammer spanner tool wrench +๐Ÿ—ก๏ธ dagger knife weapon +โš”๏ธ crossed swords crossed swords weapon +๐Ÿ’ฃ bomb comic +๐Ÿชƒ boomerang rebound repercussion +๐Ÿน bow and arrow archer arrow bow sagittarius zodiac +๐Ÿ›ก๏ธ shield weapon +๐Ÿชš carpentry saw carpenter lumber saw tool +๐Ÿ”ง wrench spanner tool +๐Ÿช› screwdriver screw tool +๐Ÿ”ฉ nut and bolt bolt nut tool +โš™๏ธ gear cog cogwheel tool +๐Ÿ—œ๏ธ clamp compress tool vice +โš–๏ธ balance scale balance justice libra scale zodiac +๐Ÿฆฏ white cane accessibility blind +๐Ÿ”— link +โ›“๏ธ chains chain +๐Ÿช hook catch crook curve ensnare selling point +๐Ÿงฐ toolbox chest mechanic tool +๐Ÿงฒ magnet attraction horseshoe magnetic +๐Ÿชœ ladder climb rung step +โš—๏ธ alembic chemistry tool +๐Ÿงช test tube chemist chemistry experiment lab science +๐Ÿงซ petri dish bacteria biologist biology culture lab +๐Ÿงฌ dna biologist evolution gene genetics life +๐Ÿ”ฌ microscope science tool +๐Ÿ”ญ telescope science tool +๐Ÿ“ก satellite antenna antenna dish satellite +๐Ÿ’‰ syringe medicine needle shot sick +๐Ÿฉธ drop of blood bleed blood donation injury medicine menstruation +๐Ÿ’Š pill doctor medicine sick +๐Ÿฉน adhesive bandage bandage +๐Ÿฉผ crutch cane disability hurt mobility aid stick +๐Ÿฉบ stethoscope doctor heart medicine +๐Ÿฉป x-ray bones doctor medical skeleton +๐Ÿšช door +๐Ÿ›— elevator accessibility hoist lift +๐Ÿชž mirror reflection reflector speculum +๐ŸชŸ window frame fresh air opening transparent view +๐Ÿ›๏ธ bed hotel sleep +๐Ÿ›‹๏ธ couch and lamp couch hotel lamp +๐Ÿช‘ chair seat sit +๐Ÿšฝ toilet +๐Ÿช  plunger force cup plumber suction toilet +๐Ÿšฟ shower water +๐Ÿ› bathtub bath +๐Ÿชค mouse trap bait mousetrap snare trap +๐Ÿช’ razor sharp shave +๐Ÿงด lotion bottle lotion moisturizer shampoo sunscreen +๐Ÿงท safety pin diaper punk rock +๐Ÿงน broom cleaning sweeping witch +๐Ÿงบ basket farming laundry picnic +๐Ÿงป roll of paper paper towels toilet paper +๐Ÿชฃ bucket cask pail vat +๐Ÿงผ soap bar bathing cleaning lather soapdish +๐Ÿซง bubbles burp clean soap underwater +๐Ÿชฅ toothbrush bathroom brush clean dental hygiene teeth +๐Ÿงฝ sponge absorbing cleaning porous +๐Ÿงฏ fire extinguisher extinguish fire quench +๐Ÿ›’ shopping cart cart shopping trolley +๐Ÿšฌ cigarette smoking +โšฐ๏ธ coffin death +๐Ÿชฆ headstone cemetery grave graveyard tombstone +โšฑ๏ธ funeral urn ashes death funeral urn +๐Ÿงฟ nazar amulet bead charm evil-eye nazar talisman +๐Ÿชฌ hamsa amulet fatima hand mary miriam protection +๐Ÿ—ฟ moai face moyai statue +๐Ÿชง placard demonstration picket protest sign +๐Ÿชช identification card credentials id license security +#Symbols +๐Ÿง atm sign atm automated bank teller +๐Ÿšฎ litter in bin sign litter litter bin +๐Ÿšฐ potable water drinking potable water +โ™ฟ wheelchair symbol access +๐Ÿšน menโ€™s room bathroom lavatory man restroom toilet wc +๐Ÿšบ womenโ€™s room bathroom lavatory restroom toilet wc woman +๐Ÿšป restroom bathroom lavatory toilet wc +๐Ÿšผ baby symbol baby changing +๐Ÿšพ water closet bathroom closet lavatory restroom toilet water wc +๐Ÿ›‚ passport control control passport +๐Ÿ›ƒ customs +๐Ÿ›„ baggage claim baggage claim +๐Ÿ›… left luggage baggage locker luggage +โš ๏ธ warning +๐Ÿšธ children crossing child crossing pedestrian traffic +โ›” no entry entry forbidden no not prohibited traffic +๐Ÿšซ prohibited entry forbidden no not +๐Ÿšณ no bicycles bicycle bike forbidden no prohibited +๐Ÿšญ no smoking forbidden no not prohibited smoking +๐Ÿšฏ no littering forbidden litter no not prohibited +๐Ÿšฑ non-potable water non-drinking non-potable water +๐Ÿšท no pedestrians forbidden no not pedestrian prohibited +๐Ÿ“ต no mobile phones cell forbidden mobile no phone +๐Ÿ”ž no one under eighteen 18 age restriction eighteen prohibited underage +โ˜ข๏ธ radioactive sign +โ˜ฃ๏ธ biohazard sign +โฌ†๏ธ up arrow arrow cardinal direction north +โ†—๏ธ up-right arrow arrow direction intercardinal northeast +โžก๏ธ right arrow arrow cardinal direction east +โ†˜๏ธ down-right arrow arrow direction intercardinal southeast +โฌ‡๏ธ down arrow arrow cardinal direction down south +โ†™๏ธ down-left arrow arrow direction intercardinal southwest +โฌ…๏ธ left arrow arrow cardinal direction west +โ†–๏ธ up-left arrow arrow direction intercardinal northwest +โ†•๏ธ up-down arrow arrow +โ†”๏ธ left-right arrow arrow +โ†ฉ๏ธ right arrow curving left arrow +โ†ช๏ธ left arrow curving right arrow +๐Ÿ”ƒ clockwise vertical arrows arrow clockwise reload +๐Ÿ”„ counterclockwise arrows button anticlockwise arrow counterclockwise withershins +๐Ÿ”™ back arrow arrow back +๐Ÿ”š end arrow arrow end +๐Ÿ”› on! arrow arrow mark on on! +๐Ÿ”œ soon arrow arrow soon +๐Ÿ” top arrow arrow top up +๐Ÿ› place of worship religion worship +โš›๏ธ atom symbol atheist atom +๐Ÿ•‰๏ธ om hindu religion +โœก๏ธ star of david david jew jewish religion star +โ˜ธ๏ธ wheel of dharma buddhist dharma religion wheel +โ˜ฏ๏ธ yin yang religion tao taoist yang yin +โœ๏ธ latin cross christian cross religion +โ˜ฆ๏ธ orthodox cross christian cross religion +โ˜ช๏ธ star and crescent islam muslim religion +โ˜ฎ๏ธ peace symbol peace +๐Ÿ•Ž menorah candelabrum candlestick religion +๐Ÿ”ฏ dotted six-pointed star fortune star +๐Ÿชฏ khanda religion sikh +โ™ˆ aries ram zodiac +โ™‰ taurus bull ox zodiac +โ™Š gemini twins zodiac +โ™‹ cancer crab zodiac +โ™Œ leo lion zodiac +โ™ virgo zodiac +โ™Ž libra balance justice scales zodiac +โ™ scorpio scorpion scorpius zodiac +โ™ sagittarius archer zodiac +โ™‘ capricorn goat zodiac +โ™’ aquarius bearer water zodiac +โ™“ pisces fish zodiac +โ›Ž ophiuchus bearer serpent snake zodiac +๐Ÿ”€ shuffle tracks button arrow crossed +๐Ÿ” repeat button arrow clockwise repeat +๐Ÿ”‚ repeat single button arrow clockwise once +โฉ fast-forward button arrow double fast forward +โญ๏ธ next track button arrow next scene next track triangle +โฏ๏ธ play or pause button arrow pause play right triangle +โช fast reverse button arrow double rewind +โฎ๏ธ last track button arrow previous scene previous track triangle +๐Ÿ”ผ upwards button arrow button +โซ fast up button arrow double +๐Ÿ”ฝ downwards button arrow button down +โฌ fast down button arrow double down +โธ๏ธ pause button bar double pause vertical +โน๏ธ stop button square stop +โบ๏ธ record button circle record +โ๏ธ eject button eject +๐ŸŽฆ cinema camera film movie +๐Ÿ”… dim button brightness dim low +๐Ÿ”† bright button bright brightness +๐Ÿ“ถ antenna bars antenna bar cell mobile phone +๐Ÿ›œ wireless computer internet network +๐Ÿ“ณ vibration mode cell mobile mode phone telephone vibration +๐Ÿ“ด mobile phone off cell mobile off phone telephone +โ™€๏ธ female sign woman +โ™‚๏ธ male sign man +โšง๏ธ transgender symbol transgender +โœ–๏ธ multiply ร— cancel multiplication sign x +โž• plus + math sign +โž– minus - โˆ’ math sign +โž— divide รท division math sign +๐ŸŸฐ heavy equals sign equality math +โ™พ๏ธ infinity forever unbounded universal +โ€ผ๏ธ double exclamation mark ! !! bangbang exclamation mark +โ‰๏ธ exclamation question mark ! !? ? exclamation interrobang mark punctuation question +โ“ red question mark ? mark punctuation question +โ” white question mark ? mark outlined punctuation question +โ• white exclamation mark ! exclamation mark outlined punctuation +โ— red exclamation mark ! exclamation mark punctuation +ใ€ฐ๏ธ wavy dash dash punctuation wavy +๐Ÿ’ฑ currency exchange bank currency exchange money +๐Ÿ’ฒ heavy dollar sign currency dollar money +โš•๏ธ medical symbol aesculapius medicine staff +โ™ป๏ธ recycling symbol recycle +โšœ๏ธ fleur-de-lis +๐Ÿ”ฑ trident emblem anchor emblem ship tool trident +๐Ÿ“› name badge badge name +๐Ÿ”ฐ japanese symbol for beginner beginner chevron japanese leaf +โญ• hollow red circle circle large o red +โœ… check mark button โœ“ button check mark +โ˜‘๏ธ check box with check โœ“ box check +โœ”๏ธ check mark โœ“ check mark +โŒ cross mark ร— cancel cross mark multiplication multiply x +โŽ cross mark button ร— mark square x +โžฐ curly loop curl loop +โžฟ double curly loop curl double loop +ใ€ฝ๏ธ part alternation mark mark part +โœณ๏ธ eight-spoked asterisk * asterisk +โœด๏ธ eight-pointed star * star +โ‡๏ธ sparkle * +ยฉ๏ธ copyright c +ยฎ๏ธ registered r +โ„ข๏ธ trade mark mark tm trademark +#๏ธโƒฃ keycap: # keycap +*๏ธโƒฃ keycap: * keycap +0๏ธโƒฃ keycap: 0 keycap +1๏ธโƒฃ keycap: 1 keycap +2๏ธโƒฃ keycap: 2 keycap +3๏ธโƒฃ keycap: 3 keycap +4๏ธโƒฃ keycap: 4 keycap +5๏ธโƒฃ keycap: 5 keycap +6๏ธโƒฃ keycap: 6 keycap +7๏ธโƒฃ keycap: 7 keycap +8๏ธโƒฃ keycap: 8 keycap +9๏ธโƒฃ keycap: 9 keycap +๐Ÿ”Ÿ keycap: 10 keycap +๐Ÿ”  input latin uppercase abcd input latin letters uppercase +๐Ÿ”ก input latin lowercase abcd input latin letters lowercase +๐Ÿ”ข input numbers 1234 input numbers +๐Ÿ”ฃ input symbols ใ€’โ™ช&% input +๐Ÿ”ค input latin letters abc alphabet input latin letters +โ„น๏ธ information i +ใŠ—๏ธ japanese โ€œcongratulationsโ€ button โ€œcongratulationsโ€ ideograph japanese ็ฅ +ใŠ™๏ธ japanese โ€œsecretโ€ button โ€œsecretโ€ ideograph japanese ็ง˜ +๐Ÿ”ด red circle circle geometric red +๐ŸŸ  orange circle circle orange +๐ŸŸก yellow circle circle yellow +๐ŸŸข green circle circle green +๐Ÿ”ต blue circle blue circle geometric +๐ŸŸฃ purple circle circle purple +๐ŸŸค brown circle brown circle +โšซ black circle circle geometric +โšช white circle circle geometric +๐ŸŸฅ red square red square +๐ŸŸง orange square orange square +๐ŸŸจ yellow square square yellow +๐ŸŸฉ green square green square +๐ŸŸฆ blue square blue square +๐ŸŸช purple square purple square +๐ŸŸซ brown square brown square +โฌ› black large square geometric square +โฌœ white large square geometric square +๐Ÿ”ถ large orange diamond diamond geometric orange +๐Ÿ”ท large blue diamond blue diamond geometric +๐Ÿ”ธ small orange diamond diamond geometric orange +๐Ÿ”น small blue diamond blue diamond geometric +๐Ÿ”บ red triangle pointed up geometric red +๐Ÿ”ป red triangle pointed down down geometric red +๐Ÿ’  diamond with a dot comic diamond geometric inside +๐Ÿ”˜ radio button button geometric radio +๐Ÿ”ณ white square button button geometric outlined square +๐Ÿ”ฒ black square button button geometric square +#Flags +๐Ÿ chequered flag checkered chequered racing +๐Ÿšฉ triangular flag post +๐ŸŽŒ crossed flags celebration cross crossed japanese +๐Ÿด black flag waving +๐Ÿณ๏ธ white flag waving +๐Ÿณ๏ธโ€๐ŸŒˆ rainbow flag pride rainbow +๐Ÿณ๏ธโ€โšง๏ธ transgender flag flag light blue pink transgender white +๐Ÿดโ€โ˜ ๏ธ pirate flag jolly roger pirate plunder treasure +๐Ÿ‡ฆ๐Ÿ‡จ flag: ascension island flag +๐Ÿ‡ฆ๐Ÿ‡ฉ flag: andorra flag +๐Ÿ‡ฆ๐Ÿ‡ช flag: united arab emirates flag +๐Ÿ‡ฆ๐Ÿ‡ซ flag: afghanistan flag +๐Ÿ‡ฆ๐Ÿ‡ฌ flag: antigua & barbuda flag +๐Ÿ‡ฆ๐Ÿ‡ฎ flag: anguilla flag +๐Ÿ‡ฆ๐Ÿ‡ฑ flag: albania flag +๐Ÿ‡ฆ๐Ÿ‡ฒ flag: armenia flag +๐Ÿ‡ฆ๐Ÿ‡ด flag: angola flag +๐Ÿ‡ฆ๐Ÿ‡ถ flag: antarctica flag +๐Ÿ‡ฆ๐Ÿ‡ท flag: argentina flag +๐Ÿ‡ฆ๐Ÿ‡ธ flag: american samoa flag +๐Ÿ‡ฆ๐Ÿ‡น flag: austria flag +๐Ÿ‡ฆ๐Ÿ‡บ flag: australia flag +๐Ÿ‡ฆ๐Ÿ‡ผ flag: aruba flag +๐Ÿ‡ฆ๐Ÿ‡ฝ flag: รฅland islands flag +๐Ÿ‡ฆ๐Ÿ‡ฟ flag: azerbaijan flag +๐Ÿ‡ง๐Ÿ‡ฆ flag: bosnia & herzegovina flag +๐Ÿ‡ง๐Ÿ‡ง flag: barbados flag +๐Ÿ‡ง๐Ÿ‡ฉ flag: bangladesh flag +๐Ÿ‡ง๐Ÿ‡ช flag: belgium flag +๐Ÿ‡ง๐Ÿ‡ซ flag: burkina faso flag +๐Ÿ‡ง๐Ÿ‡ฌ flag: bulgaria flag +๐Ÿ‡ง๐Ÿ‡ญ flag: bahrain flag +๐Ÿ‡ง๐Ÿ‡ฎ flag: burundi flag +๐Ÿ‡ง๐Ÿ‡ฏ flag: benin flag +๐Ÿ‡ง๐Ÿ‡ฑ flag: st. barthรฉlemy flag +๐Ÿ‡ง๐Ÿ‡ฒ flag: bermuda flag +๐Ÿ‡ง๐Ÿ‡ณ flag: brunei flag +๐Ÿ‡ง๐Ÿ‡ด flag: bolivia flag +๐Ÿ‡ง๐Ÿ‡ถ flag: caribbean netherlands flag +๐Ÿ‡ง๐Ÿ‡ท flag: brazil flag +๐Ÿ‡ง๐Ÿ‡ธ flag: bahamas flag +๐Ÿ‡ง๐Ÿ‡น flag: bhutan flag +๐Ÿ‡ง๐Ÿ‡ป flag: bouvet island flag +๐Ÿ‡ง๐Ÿ‡ผ flag: botswana flag +๐Ÿ‡ง๐Ÿ‡พ flag: belarus flag +๐Ÿ‡ง๐Ÿ‡ฟ flag: belize flag +๐Ÿ‡จ๐Ÿ‡ฆ flag: canada flag +๐Ÿ‡จ๐Ÿ‡จ flag: cocos (keeling) islands flag +๐Ÿ‡จ๐Ÿ‡ฉ flag: congo - kinshasa flag +๐Ÿ‡จ๐Ÿ‡ซ flag: central african republic flag +๐Ÿ‡จ๐Ÿ‡ฌ flag: congo - brazzaville flag +๐Ÿ‡จ๐Ÿ‡ญ flag: switzerland flag +๐Ÿ‡จ๐Ÿ‡ฎ flag: cรดte dโ€™ivoire flag +๐Ÿ‡จ๐Ÿ‡ฐ flag: cook islands flag +๐Ÿ‡จ๐Ÿ‡ฑ flag: chile flag +๐Ÿ‡จ๐Ÿ‡ฒ flag: cameroon flag +๐Ÿ‡จ๐Ÿ‡ณ flag: china flag +๐Ÿ‡จ๐Ÿ‡ด flag: colombia flag +๐Ÿ‡จ๐Ÿ‡ต flag: clipperton island flag +๐Ÿ‡จ๐Ÿ‡ท flag: costa rica flag +๐Ÿ‡จ๐Ÿ‡บ flag: cuba flag +๐Ÿ‡จ๐Ÿ‡ป flag: cape verde flag +๐Ÿ‡จ๐Ÿ‡ผ flag: curaรงao flag +๐Ÿ‡จ๐Ÿ‡ฝ flag: christmas island flag +๐Ÿ‡จ๐Ÿ‡พ flag: cyprus flag +๐Ÿ‡จ๐Ÿ‡ฟ flag: czechia flag +๐Ÿ‡ฉ๐Ÿ‡ช flag: germany flag +๐Ÿ‡ฉ๐Ÿ‡ฌ flag: diego garcia flag +๐Ÿ‡ฉ๐Ÿ‡ฏ flag: djibouti flag +๐Ÿ‡ฉ๐Ÿ‡ฐ flag: denmark flag +๐Ÿ‡ฉ๐Ÿ‡ฒ flag: dominica flag +๐Ÿ‡ฉ๐Ÿ‡ด flag: dominican republic flag +๐Ÿ‡ฉ๐Ÿ‡ฟ flag: algeria flag +๐Ÿ‡ช๐Ÿ‡ฆ flag: ceuta & melilla flag +๐Ÿ‡ช๐Ÿ‡จ flag: ecuador flag +๐Ÿ‡ช๐Ÿ‡ช flag: estonia flag +๐Ÿ‡ช๐Ÿ‡ฌ flag: egypt flag +๐Ÿ‡ช๐Ÿ‡ญ flag: western sahara flag +๐Ÿ‡ช๐Ÿ‡ท flag: eritrea flag +๐Ÿ‡ช๐Ÿ‡ธ flag: spain flag +๐Ÿ‡ช๐Ÿ‡น flag: ethiopia flag +๐Ÿ‡ช๐Ÿ‡บ flag: european union flag +๐Ÿ‡ซ๐Ÿ‡ฎ flag: finland flag +๐Ÿ‡ซ๐Ÿ‡ฏ flag: fiji flag +๐Ÿ‡ซ๐Ÿ‡ฐ flag: falkland islands flag +๐Ÿ‡ซ๐Ÿ‡ฒ flag: micronesia flag +๐Ÿ‡ซ๐Ÿ‡ด flag: faroe islands flag +๐Ÿ‡ซ๐Ÿ‡ท flag: france flag +๐Ÿ‡ฌ๐Ÿ‡ฆ flag: gabon flag +๐Ÿ‡ฌ๐Ÿ‡ง flag: united kingdom flag +๐Ÿ‡ฌ๐Ÿ‡ฉ flag: grenada flag +๐Ÿ‡ฌ๐Ÿ‡ช flag: georgia flag +๐Ÿ‡ฌ๐Ÿ‡ซ flag: french guiana flag +๐Ÿ‡ฌ๐Ÿ‡ฌ flag: guernsey flag +๐Ÿ‡ฌ๐Ÿ‡ญ flag: ghana flag +๐Ÿ‡ฌ๐Ÿ‡ฎ flag: gibraltar flag +๐Ÿ‡ฌ๐Ÿ‡ฑ flag: greenland flag +๐Ÿ‡ฌ๐Ÿ‡ฒ flag: gambia flag +๐Ÿ‡ฌ๐Ÿ‡ณ flag: guinea flag +๐Ÿ‡ฌ๐Ÿ‡ต flag: guadeloupe flag +๐Ÿ‡ฌ๐Ÿ‡ถ flag: equatorial guinea flag +๐Ÿ‡ฌ๐Ÿ‡ท flag: greece flag +๐Ÿ‡ฌ๐Ÿ‡ธ flag: south georgia & south sandwich islands flag +๐Ÿ‡ฌ๐Ÿ‡น flag: guatemala flag +๐Ÿ‡ฌ๐Ÿ‡บ flag: guam flag +๐Ÿ‡ฌ๐Ÿ‡ผ flag: guinea-bissau flag +๐Ÿ‡ฌ๐Ÿ‡พ flag: guyana flag +๐Ÿ‡ญ๐Ÿ‡ฐ flag: hong kong sar china flag +๐Ÿ‡ญ๐Ÿ‡ฒ flag: heard & mcdonald islands flag +๐Ÿ‡ญ๐Ÿ‡ณ flag: honduras flag +๐Ÿ‡ญ๐Ÿ‡ท flag: croatia flag +๐Ÿ‡ญ๐Ÿ‡น flag: haiti flag +๐Ÿ‡ญ๐Ÿ‡บ flag: hungary flag +๐Ÿ‡ฎ๐Ÿ‡จ flag: canary islands flag +๐Ÿ‡ฎ๐Ÿ‡ฉ flag: indonesia flag +๐Ÿ‡ฎ๐Ÿ‡ช flag: ireland flag +๐Ÿ‡ฎ๐Ÿ‡ฑ flag: israel flag +๐Ÿ‡ฎ๐Ÿ‡ฒ flag: isle of man flag +๐Ÿ‡ฎ๐Ÿ‡ณ flag: india flag +๐Ÿ‡ฎ๐Ÿ‡ด flag: british indian ocean territory flag +๐Ÿ‡ฎ๐Ÿ‡ถ flag: iraq flag +๐Ÿ‡ฎ๐Ÿ‡ท flag: iran flag +๐Ÿ‡ฎ๐Ÿ‡ธ flag: iceland flag +๐Ÿ‡ฎ๐Ÿ‡น flag: italy flag +๐Ÿ‡ฏ๐Ÿ‡ช flag: jersey flag +๐Ÿ‡ฏ๐Ÿ‡ฒ flag: jamaica flag +๐Ÿ‡ฏ๐Ÿ‡ด flag: jordan flag +๐Ÿ‡ฏ๐Ÿ‡ต flag: japan flag +๐Ÿ‡ฐ๐Ÿ‡ช flag: kenya flag +๐Ÿ‡ฐ๐Ÿ‡ฌ flag: kyrgyzstan flag +๐Ÿ‡ฐ๐Ÿ‡ญ flag: cambodia flag +๐Ÿ‡ฐ๐Ÿ‡ฎ flag: kiribati flag +๐Ÿ‡ฐ๐Ÿ‡ฒ flag: comoros flag +๐Ÿ‡ฐ๐Ÿ‡ณ flag: st. kitts & nevis flag +๐Ÿ‡ฐ๐Ÿ‡ต flag: north korea flag +๐Ÿ‡ฐ๐Ÿ‡ท flag: south korea flag +๐Ÿ‡ฐ๐Ÿ‡ผ flag: kuwait flag +๐Ÿ‡ฐ๐Ÿ‡พ flag: cayman islands flag +๐Ÿ‡ฐ๐Ÿ‡ฟ flag: kazakhstan flag +๐Ÿ‡ฑ๐Ÿ‡ฆ flag: laos flag +๐Ÿ‡ฑ๐Ÿ‡ง flag: lebanon flag +๐Ÿ‡ฑ๐Ÿ‡จ flag: st. lucia flag +๐Ÿ‡ฑ๐Ÿ‡ฎ flag: liechtenstein flag +๐Ÿ‡ฑ๐Ÿ‡ฐ flag: sri lanka flag +๐Ÿ‡ฑ๐Ÿ‡ท flag: liberia flag +๐Ÿ‡ฑ๐Ÿ‡ธ flag: lesotho flag +๐Ÿ‡ฑ๐Ÿ‡น flag: lithuania flag +๐Ÿ‡ฑ๐Ÿ‡บ flag: luxembourg flag +๐Ÿ‡ฑ๐Ÿ‡ป flag: latvia flag +๐Ÿ‡ฑ๐Ÿ‡พ flag: libya flag +๐Ÿ‡ฒ๐Ÿ‡ฆ flag: morocco flag +๐Ÿ‡ฒ๐Ÿ‡จ flag: monaco flag +๐Ÿ‡ฒ๐Ÿ‡ฉ flag: moldova flag +๐Ÿ‡ฒ๐Ÿ‡ช flag: montenegro flag +๐Ÿ‡ฒ๐Ÿ‡ซ flag: st. martin flag +๐Ÿ‡ฒ๐Ÿ‡ฌ flag: madagascar flag +๐Ÿ‡ฒ๐Ÿ‡ญ flag: marshall islands flag +๐Ÿ‡ฒ๐Ÿ‡ฐ flag: north macedonia flag +๐Ÿ‡ฒ๐Ÿ‡ฑ flag: mali flag +๐Ÿ‡ฒ๐Ÿ‡ฒ flag: myanmar (burma) flag +๐Ÿ‡ฒ๐Ÿ‡ณ flag: mongolia flag +๐Ÿ‡ฒ๐Ÿ‡ด flag: macao sar china flag +๐Ÿ‡ฒ๐Ÿ‡ต flag: northern mariana islands flag +๐Ÿ‡ฒ๐Ÿ‡ถ flag: martinique flag +๐Ÿ‡ฒ๐Ÿ‡ท flag: mauritania flag +๐Ÿ‡ฒ๐Ÿ‡ธ flag: montserrat flag +๐Ÿ‡ฒ๐Ÿ‡น flag: malta flag +๐Ÿ‡ฒ๐Ÿ‡บ flag: mauritius flag +๐Ÿ‡ฒ๐Ÿ‡ป flag: maldives flag +๐Ÿ‡ฒ๐Ÿ‡ผ flag: malawi flag +๐Ÿ‡ฒ๐Ÿ‡ฝ flag: mexico flag +๐Ÿ‡ฒ๐Ÿ‡พ flag: malaysia flag +๐Ÿ‡ฒ๐Ÿ‡ฟ flag: mozambique flag +๐Ÿ‡ณ๐Ÿ‡ฆ flag: namibia flag +๐Ÿ‡ณ๐Ÿ‡จ flag: new caledonia flag +๐Ÿ‡ณ๐Ÿ‡ช flag: niger flag +๐Ÿ‡ณ๐Ÿ‡ซ flag: norfolk island flag +๐Ÿ‡ณ๐Ÿ‡ฌ flag: nigeria flag +๐Ÿ‡ณ๐Ÿ‡ฎ flag: nicaragua flag +๐Ÿ‡ณ๐Ÿ‡ฑ flag: netherlands flag +๐Ÿ‡ณ๐Ÿ‡ด flag: norway flag +๐Ÿ‡ณ๐Ÿ‡ต flag: nepal flag +๐Ÿ‡ณ๐Ÿ‡ท flag: nauru flag +๐Ÿ‡ณ๐Ÿ‡บ flag: niue flag +๐Ÿ‡ณ๐Ÿ‡ฟ flag: new zealand flag +๐Ÿ‡ด๐Ÿ‡ฒ flag: oman flag +๐Ÿ‡ต๐Ÿ‡ฆ flag: panama flag +๐Ÿ‡ต๐Ÿ‡ช flag: peru flag +๐Ÿ‡ต๐Ÿ‡ซ flag: french polynesia flag +๐Ÿ‡ต๐Ÿ‡ฌ flag: papua new guinea flag +๐Ÿ‡ต๐Ÿ‡ญ flag: philippines flag +๐Ÿ‡ต๐Ÿ‡ฐ flag: pakistan flag +๐Ÿ‡ต๐Ÿ‡ฑ flag: poland flag +๐Ÿ‡ต๐Ÿ‡ฒ flag: st. pierre & miquelon flag +๐Ÿ‡ต๐Ÿ‡ณ flag: pitcairn islands flag +๐Ÿ‡ต๐Ÿ‡ท flag: puerto rico flag +๐Ÿ‡ต๐Ÿ‡ธ flag: palestinian territories flag +๐Ÿ‡ต๐Ÿ‡น flag: portugal flag +๐Ÿ‡ต๐Ÿ‡ผ flag: palau flag +๐Ÿ‡ต๐Ÿ‡พ flag: paraguay flag +๐Ÿ‡ถ๐Ÿ‡ฆ flag: qatar flag +๐Ÿ‡ท๐Ÿ‡ช flag: rรฉunion flag +๐Ÿ‡ท๐Ÿ‡ด flag: romania flag +๐Ÿ‡ท๐Ÿ‡ธ flag: serbia flag +๐Ÿ‡ท๐Ÿ‡บ flag: russia flag +๐Ÿ‡ท๐Ÿ‡ผ flag: rwanda flag +๐Ÿ‡ธ๐Ÿ‡ฆ flag: saudi arabia flag +๐Ÿ‡ธ๐Ÿ‡ง flag: solomon islands flag +๐Ÿ‡ธ๐Ÿ‡จ flag: seychelles flag +๐Ÿ‡ธ๐Ÿ‡ฉ flag: sudan flag +๐Ÿ‡ธ๐Ÿ‡ช flag: sweden flag +๐Ÿ‡ธ๐Ÿ‡ฌ flag: singapore flag +๐Ÿ‡ธ๐Ÿ‡ญ flag: st. helena flag +๐Ÿ‡ธ๐Ÿ‡ฎ flag: slovenia flag +๐Ÿ‡ธ๐Ÿ‡ฏ flag: svalbard & jan mayen flag +๐Ÿ‡ธ๐Ÿ‡ฐ flag: slovakia flag +๐Ÿ‡ธ๐Ÿ‡ฑ flag: sierra leone flag +๐Ÿ‡ธ๐Ÿ‡ฒ flag: san marino flag +๐Ÿ‡ธ๐Ÿ‡ณ flag: senegal flag +๐Ÿ‡ธ๐Ÿ‡ด flag: somalia flag +๐Ÿ‡ธ๐Ÿ‡ท flag: suriname flag +๐Ÿ‡ธ๐Ÿ‡ธ flag: south sudan flag +๐Ÿ‡ธ๐Ÿ‡น flag: sรฃo tomรฉ & prรญncipe flag +๐Ÿ‡ธ๐Ÿ‡ป flag: el salvador flag +๐Ÿ‡ธ๐Ÿ‡ฝ flag: sint maarten flag +๐Ÿ‡ธ๐Ÿ‡พ flag: syria flag +๐Ÿ‡ธ๐Ÿ‡ฟ flag: eswatini flag +๐Ÿ‡น๐Ÿ‡ฆ flag: tristan da cunha flag +๐Ÿ‡น๐Ÿ‡จ flag: turks & caicos islands flag +๐Ÿ‡น๐Ÿ‡ฉ flag: chad flag +๐Ÿ‡น๐Ÿ‡ซ flag: french southern territories flag +๐Ÿ‡น๐Ÿ‡ฌ flag: togo flag +๐Ÿ‡น๐Ÿ‡ญ flag: thailand flag +๐Ÿ‡น๐Ÿ‡ฏ flag: tajikistan flag +๐Ÿ‡น๐Ÿ‡ฐ flag: tokelau flag +๐Ÿ‡น๐Ÿ‡ฑ flag: timor-leste flag +๐Ÿ‡น๐Ÿ‡ฒ flag: turkmenistan flag +๐Ÿ‡น๐Ÿ‡ณ flag: tunisia flag +๐Ÿ‡น๐Ÿ‡ด flag: tonga flag +๐Ÿ‡น๐Ÿ‡ท flag: turkey flag +๐Ÿ‡น๐Ÿ‡น flag: trinidad & tobago flag +๐Ÿ‡น๐Ÿ‡ป flag: tuvalu flag +๐Ÿ‡น๐Ÿ‡ผ flag: taiwan flag +๐Ÿ‡น๐Ÿ‡ฟ flag: tanzania flag +๐Ÿ‡บ๐Ÿ‡ฆ flag: ukraine flag +๐Ÿ‡บ๐Ÿ‡ฌ flag: uganda flag +๐Ÿ‡บ๐Ÿ‡ฒ flag: u.s. outlying islands flag +๐Ÿ‡บ๐Ÿ‡ณ flag: united nations flag +๐Ÿ‡บ๐Ÿ‡ธ flag: united states flag +๐Ÿ‡บ๐Ÿ‡พ flag: uruguay flag +๐Ÿ‡บ๐Ÿ‡ฟ flag: uzbekistan flag +๐Ÿ‡ป๐Ÿ‡ฆ flag: vatican city flag +๐Ÿ‡ป๐Ÿ‡จ flag: st. vincent & grenadines flag +๐Ÿ‡ป๐Ÿ‡ช flag: venezuela flag +๐Ÿ‡ป๐Ÿ‡ฌ flag: british virgin islands flag +๐Ÿ‡ป๐Ÿ‡ฎ flag: u.s. virgin islands flag +๐Ÿ‡ป๐Ÿ‡ณ flag: vietnam flag +๐Ÿ‡ป๐Ÿ‡บ flag: vanuatu flag +๐Ÿ‡ผ๐Ÿ‡ซ flag: wallis & futuna flag +๐Ÿ‡ผ๐Ÿ‡ธ flag: samoa flag +๐Ÿ‡ฝ๐Ÿ‡ฐ flag: kosovo flag +๐Ÿ‡พ๐Ÿ‡ช flag: yemen flag +๐Ÿ‡พ๐Ÿ‡น flag: mayotte flag +๐Ÿ‡ฟ๐Ÿ‡ฆ flag: south africa flag +๐Ÿ‡ฟ๐Ÿ‡ฒ flag: zambia flag +๐Ÿ‡ฟ๐Ÿ‡ผ flag: zimbabwe flag +'''; diff --git a/lib/src/view/chat/chat_timeline.dart b/lib/src/view/chat/chat_timeline.dart index 61b6953..fbd9274 100644 --- a/lib/src/view/chat/chat_timeline.dart +++ b/lib/src/view/chat/chat_timeline.dart @@ -1,5 +1,6 @@ import 'package:fireplace/src/view/chat/message_actions.dart'; import 'package:flutter/material.dart'; +import 'package:fireplace/src/view/chat/message_actions_overlay.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:fireplace/src/model/chat/message_selection.dart'; import 'package:fireplace/src/styles/design_tokens.dart'; @@ -83,7 +84,12 @@ class ChatTimeline extends ConsumerWidget { key: const Key('messageTimeline'), controller: scrollController, reverse: true, - padding: EdgeInsets.all(12), + // The side inset equals the held-message menu's, so a + // message with room for its menu is lifted without moving. + padding: const EdgeInsets.symmetric( + horizontal: MessageMenuMetrics.edge, + vertical: 12, + ), itemCount: msgs.length, itemBuilder: (_, i) { final index = msgs.length - 1 - i; diff --git a/lib/src/view/chat/emoji_picker.dart b/lib/src/view/chat/emoji_picker.dart new file mode 100644 index 0000000..c11d2c6 --- /dev/null +++ b/lib/src/view/chat/emoji_picker.dart @@ -0,0 +1,263 @@ +import 'dart:math' as math; + +import 'package:flutter/material.dart'; +import 'package:fireplace/src/db/message_mutation.dart'; +import 'package:fireplace/src/model/chat/emoji_catalog.dart'; +import 'package:fireplace/src/styles/design_tokens.dart'; +import 'package:fireplace/src/view/chat/emoji_picker_parts.dart'; + +export 'package:fireplace/src/view/chat/emoji_picker_parts.dart' + show EmojiPickerMetrics; + +/// Every emoji, by category, with a search that never leaves the device. +/// It rests at about half the screen, pulls up to full height, and closes +/// when dragged down. +class EmojiPickerSheet extends StatefulWidget { + const EmojiPickerSheet({ + super.key, + required this.onPicked, + required this.onDismissed, + this.recent = const [], + }); + final ValueChanged onPicked; + final VoidCallback onDismissed; + + /// Emoji this person reacted with before, newest first. + final List recent; + @override + State createState() => _EmojiPickerSheetState(); +} + +class _EmojiPickerSheetState extends State { + final _sheet = DraggableScrollableController(); + final _search = TextEditingController(); + final _searchFocus = FocusNode(); + ScrollController? _scroll; + List _offsets = const []; + int _section = 0; + bool _closed = false; + + @override + void initState() { + super.initState(); + _searchFocus.addListener(_onSearchFocus); + } + + @override + void dispose() { + _scroll?.removeListener(_onScroll); + _searchFocus + ..removeListener(_onSearchFocus) + ..dispose(); + _search.dispose(); + _sheet.dispose(); + super.dispose(); + } + + // Typing needs room above the keyboard, so searching opens the sheet fully. + void _onSearchFocus() { + if (_searchFocus.hasFocus) _expand(); + } + + void _expand() { + if (!_sheet.isAttached) return; + if (MediaQuery.disableAnimationsOf(context)) { + _sheet.jumpTo(1); + } else { + _sheet.animateTo( + 1, + duration: const Duration(milliseconds: 250), + curve: Curves.easeOutCubic, + ); + } + } + + void _onScroll() { + final scroll = _scroll; + if (scroll == null || !scroll.hasClients || _offsets.isEmpty) return; + var section = 0; + for (var i = 0; i < _offsets.length; i++) { + if (scroll.offset + 1 >= _offsets[i]) section = i; + } + if (section != _section) setState(() => _section = section); + } + + void _attach(ScrollController controller) { + if (identical(_scroll, controller)) return; + _scroll?.removeListener(_onScroll); + _scroll = controller..addListener(_onScroll); + } + + void _jump(int section) { + final scroll = _scroll; + if (scroll == null || !scroll.hasClients) return; + scroll.jumpTo(math.min(_offsets[section], scroll.position.maxScrollExtent)); + setState(() => _section = section); + } + + @override + Widget build(BuildContext context) { + final media = MediaQuery.of(context); + final t = FireplaceUiTokens.of(context); + final query = _search.text.trim(); + final searching = query.isNotEmpty; + final results = searching ? _results(query) : const []; + return LayoutBuilder( + builder: (context, constraints) { + final width = constraints.maxWidth; + final columns = math.max( + 1, + width ~/ (EmojiPickerMetrics.cell + EmojiPickerMetrics.rowGap), + ); + final spacing = columns == 1 + ? 0.0 + : (width - 2 * EmojiPickerMetrics.edge - columns * 38) / + (columns - 1); + final title = + EmojiPickerMetrics.sectionTitle - + 17 * 1.2 + + media.textScaler.scale(17) * 1.2; + // Text grows the search field and the block that holds it. + final grown = (media.textScaler.scale(23) - 23) * 1.2; + final recent = widget.recent.take(columns).toList(); + final sections = <(String, List)>[ + if (recent.isNotEmpty) + ('Recents', [for (final e in recent) EmojiEntry(e, e, const [])]), + for (final c in emojiCategories) (c.name, c.entries), + ]; + final offsets = []; + var at = 0.0; + for (final (_, entries) in sections) { + offsets.add(at); + final rows = (entries.length / columns).ceil(); + at += + title + + rows * EmojiPickerMetrics.cell + + (rows - 1) * EmojiPickerMetrics.rowGap; + } + _offsets = offsets; + final toolbar = + EmojiPickerMetrics.toolbarButton + + 2 * EmojiPickerMetrics.toolbarGap; + final below = searching + ? media.padding.bottom + 16 + : toolbar + 8 + media.padding.bottom; + final top = + (constraints.maxHeight - media.padding.top - 10) / + constraints.maxHeight; + final resting = math.min(EmojiPickerMetrics.restingFraction, top); + return NotificationListener( + onNotification: (notification) { + if (notification.extent <= .02 && !_closed) { + _closed = true; + widget.onDismissed(); + } + return false; + }, + child: DraggableScrollableSheet( + controller: _sheet, + initialChildSize: resting, + minChildSize: 0, + maxChildSize: top, + snap: true, + snapSizes: [resting], + builder: (context, scroll) { + _attach(scroll); + return Material( + key: const Key('emojiPicker'), + color: t.panel, + elevation: 16, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical( + top: Radius.circular(EmojiPickerMetrics.radius), + ), + ), + clipBehavior: Clip.antiAlias, + child: Stack( + children: [ + CustomScrollView( + controller: scroll, + slivers: [ + SliverPersistentHeader( + pinned: true, + delegate: EmojiSearchHeader( + color: t.panel, + grown: grown, + child: EmojiSearchField( + controller: _search, + focusNode: _searchFocus, + onChanged: (_) => setState(() {}), + ), + ), + ), + if (searching && results.isEmpty) + const SliverToBoxAdapter( + child: Padding( + padding: EdgeInsets.all(EmojiPickerMetrics.edge), + child: Text( + 'No emoji found', + key: Key('emojiSearchEmpty'), + ), + ), + ), + if (searching && results.isNotEmpty) + EmojiGrid( + entries: results, + columns: columns, + spacing: spacing, + keyPrefix: 'pickReaction', + onPicked: widget.onPicked, + ), + if (!searching) + for (final (index, (name, entries)) + in sections.indexed) ...[ + SliverToBoxAdapter( + child: EmojiSectionTitle( + name: name, + height: title, + ), + ), + EmojiGrid( + entries: entries, + columns: columns, + spacing: spacing, + keyPrefix: index == 0 && recent.isNotEmpty + ? 'recentReaction' + : 'pickReaction', + onPicked: widget.onPicked, + ), + ], + SliverToBoxAdapter(child: SizedBox(height: below)), + ], + ), + if (!searching) + Positioned( + left: 0, + right: 0, + bottom: 0, + child: EmojiCategoryToolbar( + recents: recent.isNotEmpty, + selected: _section, + onSelected: _jump, + bottom: media.padding.bottom + 8, + ), + ), + ], + ), + ); + }, + ), + ); + }, + ); + } + + List _results(String query) { + final found = searchEmoji(query); + // An emoji typed from the keyboard is offered as it is. + if (validReactionEmoji(query) && found.every((e) => e.emoji != query)) { + return [EmojiEntry(query, query, const []), ...found]; + } + return found; + } +} diff --git a/lib/src/view/chat/emoji_picker_parts.dart b/lib/src/view/chat/emoji_picker_parts.dart new file mode 100644 index 0000000..011a440 --- /dev/null +++ b/lib/src/view/chat/emoji_picker_parts.dart @@ -0,0 +1,291 @@ +import 'dart:ui' as ui; + +import 'package:flutter/cupertino.dart' show CupertinoIcons; +import 'package:flutter/material.dart'; +import 'package:fireplace/src/model/chat/emoji_catalog.dart'; +import 'package:fireplace/src/styles/design_tokens.dart'; + +/// The measured geometry of the emoji sheet, in logical pixels. +abstract final class EmojiPickerMetrics { + /// Share of the screen the sheet covers before it is pulled up. + static const double restingFraction = .541; + static const double radius = 10; + static const double edge = 16; + static const double cell = 38; + static const double rowGap = 10; + static const double emoji = 28; + static const double searchTop = 32; + static const double searchHeight = 47; + static const double headerBlock = 89.5; + static const double sectionTitle = 41; + static const double toolbarButton = 40; + static const double toolbarGap = 4; +} + +const emojiCategoryIcons = [ + CupertinoIcons.smiley, + CupertinoIcons.paw, + Icons.ramen_dining_outlined, + Icons.sports_soccer_outlined, + CupertinoIcons.car_detailed, + CupertinoIcons.lightbulb, + Icons.calculate_outlined, + CupertinoIcons.flag, +]; + +class EmojiSearchHeader extends SliverPersistentHeaderDelegate { + const EmojiSearchHeader({ + required this.color, + required this.grown, + required this.child, + }); + final Color color; + final double grown; + final Widget child; + @override + double get minExtent => EmojiPickerMetrics.headerBlock + grown; + @override + double get maxExtent => EmojiPickerMetrics.headerBlock + grown; + @override + Widget build(BuildContext context, double shrink, bool overlaps) => + ColoredBox( + color: color, + child: Stack( + children: [ + Positioned( + top: 5, + left: 0, + right: 0, + child: Center( + child: Container( + key: const Key('emojiPickerHandle'), + width: 36, + height: 5, + decoration: ShapeDecoration( + shape: const StadiumBorder(), + color: FireplaceUiTokens.of(context).secondaryText + .withValues(alpha: .7), + ), + ), + ), + ), + Positioned( + top: EmojiPickerMetrics.searchTop, + left: EmojiPickerMetrics.edge, + right: EmojiPickerMetrics.edge, + height: EmojiPickerMetrics.searchHeight + grown, + child: child, + ), + ], + ), + ); + @override + bool shouldRebuild(EmojiSearchHeader old) => + old.color != color || old.grown != grown || old.child != child; +} + +class EmojiSearchField extends StatelessWidget { + const EmojiSearchField({ + super.key, + required this.controller, + required this.focusNode, + required this.onChanged, + }); + final TextEditingController controller; + final FocusNode focusNode; + final ValueChanged onChanged; + @override + Widget build(BuildContext context) { + final t = FireplaceUiTokens.of(context); + const border = OutlineInputBorder( + borderRadius: BorderRadius.all(Radius.circular(10)), + borderSide: BorderSide.none, + ); + return TextField( + key: const Key('emojiSearch'), + controller: controller, + focusNode: focusNode, + onChanged: onChanged, + maxLength: emojiSearchLimit, + textInputAction: TextInputAction.search, + // The field keeps its measured height; long text scrolls inside it. + textAlignVertical: TextAlignVertical.center, + style: TextStyle(fontSize: 23, height: 1.2, color: t.text), + decoration: InputDecoration( + isDense: true, + counterText: '', + hintText: 'Search', + semanticCounterText: '', + hintStyle: TextStyle(fontSize: 23, height: 1.2, color: t.secondaryText), + filled: true, + fillColor: t.separator, + // With 23-point text at 1.2 line height this makes the 47-point field. + contentPadding: const EdgeInsets.symmetric(vertical: 9.7), + prefixIcon: Padding( + padding: const EdgeInsets.only(left: 8, right: 6), + child: Icon(CupertinoIcons.search, size: 24, color: t.secondaryText), + ), + prefixIconConstraints: const BoxConstraints(minWidth: 40), + border: border, + enabledBorder: border, + focusedBorder: border, + ), + ); + } +} + +class EmojiSectionTitle extends StatelessWidget { + const EmojiSectionTitle({ + super.key, + required this.name, + required this.height, + }); + final String name; + final double height; + @override + Widget build(BuildContext context) => Container( + height: height, + alignment: AlignmentDirectional.bottomStart, + padding: const EdgeInsets.fromLTRB( + EmojiPickerMetrics.edge, + 0, + EmojiPickerMetrics.edge, + 5, + ), + child: Semantics( + header: true, + child: Text( + name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 17, + height: 1.2, + fontWeight: FontWeight.w600, + color: FireplaceUiTokens.of(context).secondaryText, + ), + ), + ), + ); +} + +class EmojiGrid extends StatelessWidget { + const EmojiGrid({ + super.key, + required this.entries, + required this.columns, + required this.spacing, + required this.keyPrefix, + required this.onPicked, + }); + final List entries; + final int columns; + final double spacing; + final String keyPrefix; + final ValueChanged onPicked; + @override + Widget build(BuildContext context) => SliverPadding( + padding: const EdgeInsets.symmetric(horizontal: EmojiPickerMetrics.edge), + sliver: SliverGrid.builder( + itemCount: entries.length, + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: columns, + mainAxisExtent: EmojiPickerMetrics.cell, + mainAxisSpacing: EmojiPickerMetrics.rowGap, + crossAxisSpacing: spacing, + ), + itemBuilder: (context, index) { + final entry = entries[index]; + return Semantics( + button: true, + label: entry.name, + excludeSemantics: true, + child: InkWell( + key: ValueKey('$keyPrefix-${entry.emoji}'), + customBorder: const CircleBorder(), + onTap: () => onPicked(entry.emoji), + child: Center( + child: Text( + entry.emoji, + textScaler: TextScaler.noScaling, + style: const TextStyle( + fontSize: EmojiPickerMetrics.emoji, + height: 1, + ), + ), + ), + ), + ); + }, + ), + ); +} + +class EmojiCategoryToolbar extends StatelessWidget { + const EmojiCategoryToolbar({ + super.key, + required this.recents, + required this.selected, + required this.onSelected, + required this.bottom, + }); + final bool recents; + final int selected; + final ValueChanged onSelected; + final double bottom; + @override + Widget build(BuildContext context) { + final t = FireplaceUiTokens.of(context); + final labels = [ + if (recents) 'Recents', + for (final category in emojiCategories) category.name, + ]; + final icons = [if (recents) CupertinoIcons.clock, ...emojiCategoryIcons]; + return ClipRect( + child: BackdropFilter( + filter: ui.ImageFilter.blur(sigmaX: 20, sigmaY: 20), + child: ColoredBox( + color: t.page.withValues(alpha: .86), + child: Padding( + padding: EdgeInsets.only(bottom: bottom), + child: SizedBox( + height: + EmojiPickerMetrics.toolbarButton + + 2 * EmojiPickerMetrics.toolbarGap, + child: ListView.separated( + key: const Key('emojiCategories'), + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric( + horizontal: 2 * EmojiPickerMetrics.toolbarGap, + vertical: EmojiPickerMetrics.toolbarGap, + ), + itemCount: labels.length, + separatorBuilder: (_, _) => + const SizedBox(width: EmojiPickerMetrics.toolbarGap), + itemBuilder: (context, index) => IconButton( + key: ValueKey('emojiCategory-${labels[index]}'), + tooltip: labels[index], + isSelected: index == selected, + onPressed: () => onSelected(index), + style: IconButton.styleFrom( + fixedSize: const Size.square( + EmojiPickerMetrics.toolbarButton, + ), + minimumSize: Size.zero, + padding: EdgeInsets.zero, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + foregroundColor: t.text.withValues(alpha: .8), + backgroundColor: index == selected + ? t.text.withValues(alpha: .14) + : Colors.transparent, + ), + icon: Icon(icons[index], size: 24), + ), + ), + ), + ), + ), + ), + ); + } +} diff --git a/lib/src/view/chat/message_actions.dart b/lib/src/view/chat/message_actions.dart index 6fe40fa..b976c83 100644 --- a/lib/src/view/chat/message_actions.dart +++ b/lib/src/view/chat/message_actions.dart @@ -16,6 +16,8 @@ class MessageAction { this.reaction = false, this.emoji, this.selected = false, + this.onEmoji, + this.recentEmoji = const [], }); final String id, label; final IconData icon; @@ -23,6 +25,13 @@ class MessageAction { final bool destructive; final bool reaction, selected; final String? emoji; + + /// Set on the action that opens the full emoji picker inside the menu: + /// receives the emoji chosen there. [onSelected] is then not called. + final ValueChanged? onEmoji; + + /// Emoji to offer first in that picker, newest first. + final List recentEmoji; } /// The original long-press recognizer keeps the pointer while the overlay opens. @@ -58,7 +67,7 @@ Future showMessageActionsMenu( .shift(-origin); final previousFocus = FocusManager.instance.primaryFocus; final reducedMotion = MediaQuery.disableAnimationsOf(context); - final result = Completer(); + final result = Completer<(MessageAction?, String?)>(); final hostKey = GlobalKey(); late final OverlayEntry entry; late final LocalHistoryEntry history; @@ -73,11 +82,11 @@ Future showMessageActionsMenu( gesture: gesture, reducedMotion: reducedMotion, onDisposed: () { - if (!result.isCompleted) result.complete(null); + if (!result.isCompleted) result.complete((null, null)); }, - onClosed: (chosen) { + onClosed: (chosen, emoji) { if (result.isCompleted) return; - result.complete(chosen); + result.complete((chosen, emoji)); history.remove(); entry.remove(); entry.dispose(); @@ -87,9 +96,14 @@ Future showMessageActionsMenu( ModalRoute.of(context)?.addLocalHistoryEntry(history); Navigator.of(context).overlay!.insert(entry); if (gesture != null) gesture.onCancel = dismiss; - final chosen = await result.future; + final (chosen, emoji) = await result.future; if (previousFocus?.context != null) previousFocus!.requestFocus(); - if (context.mounted) chosen?.onSelected(); + if (!context.mounted || chosen == null) return; + if (emoji != null) { + chosen.onEmoji?.call(emoji); + } else { + chosen.onSelected(); + } } /// Selection lives in its own sheet so it cannot compete with long-press actions. diff --git a/lib/src/view/chat/message_actions_host.dart b/lib/src/view/chat/message_actions_host.dart index 222a8f9..3d65527 100644 --- a/lib/src/view/chat/message_actions_host.dart +++ b/lib/src/view/chat/message_actions_host.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import 'package:fireplace/src/view/chat/emoji_picker.dart'; import 'package:fireplace/src/view/chat/message_actions.dart'; import 'package:fireplace/src/view/chat/message_actions_overlay.dart'; @@ -18,7 +19,7 @@ class MessageActionsHost extends StatefulWidget { final Rect anchor; final List actions; final bool reducedMotion; - final ValueChanged onClosed; + final void Function(MessageAction? chosen, String? emoji) onClosed; final VoidCallback onDisposed; final Widget? preview; final MessageMenuGesture? gesture; @@ -27,8 +28,10 @@ class MessageActionsHost extends StatefulWidget { } class MessageActionsHostState extends State - with SingleTickerProviderStateMixin { + with TickerProviderStateMixin { late final AnimationController _animation; + late final AnimationController _picker; + MessageAction? _picking; bool _closing = false; @override void initState() { @@ -39,14 +42,33 @@ class MessageActionsHostState extends State ? Duration.zero : const Duration(milliseconds: 400), )..forward(); + _picker = AnimationController( + vsync: this, + duration: widget.reducedMotion + ? Duration.zero + : const Duration(milliseconds: 250), + ); + } + + /// The emoji picker replaces the reaction bar and the action list; the + /// held message stays where it is. + void _select(MessageAction action) { + if (action.onEmoji == null) { + dismiss(action); + return; + } + if (_closing || _picking != null) return; + widget.gesture?.highlighted.value = null; + setState(() => _picking = action); + _picker.forward(); } - Future dismiss(MessageAction? action) async { + Future dismiss(MessageAction? action, [String? emoji]) async { if (_closing) return; _closing = true; try { await _animation.reverse().orCancel; - if (mounted) widget.onClosed(action); + if (mounted) widget.onClosed(action, emoji); } on TickerCanceled { /* The owning overlay was disposed. */ } @@ -55,6 +77,7 @@ class MessageActionsHostState extends State @override void dispose() { _animation.dispose(); + _picker.dispose(); widget.onDisposed(); super.dispose(); } @@ -92,9 +115,43 @@ class MessageActionsHostState extends State reactions: widget.actions.where((a) => a.reaction).toList(), animation: _animation, preview: widget.preview, - gesture: widget.gesture, - onSelected: dismiss, + gesture: _picking == null ? widget.gesture : null, + controls: _picking == null ? null : ReverseAnimation(_picker), + picker: CurvedAnimation( + parent: _picker, + curve: Curves.easeOutCubic, + ), + pickerTop: + (MediaQuery.sizeOf(context).height - + MediaQuery.viewInsetsOf(context).bottom) * + (1 - EmojiPickerMetrics.restingFraction), + onSelected: _select, ), + if (_picking case final picking?) + Positioned.fill( + child: Padding( + padding: EdgeInsets.only( + bottom: MediaQuery.viewInsetsOf(context).bottom, + ), + child: SlideTransition( + position: + Tween( + begin: const Offset(0, 1), + end: Offset.zero, + ).animate( + CurvedAnimation( + parent: _picker, + curve: Curves.easeOutCubic, + ), + ), + child: EmojiPickerSheet( + recent: picking.recentEmoji, + onPicked: (emoji) => dismiss(picking, emoji), + onDismissed: () => dismiss(null), + ), + ), + ), + ), ], ), ), diff --git a/lib/src/view/chat/message_actions_overlay.dart b/lib/src/view/chat/message_actions_overlay.dart index 18b0bee..cd6e311 100644 --- a/lib/src/view/chat/message_actions_overlay.dart +++ b/lib/src/view/chat/message_actions_overlay.dart @@ -4,6 +4,9 @@ import 'dart:ui' as ui; import 'package:flutter/material.dart'; import 'package:fireplace/src/styles/design_tokens.dart'; import 'package:fireplace/src/view/chat/message_actions.dart'; +import 'package:fireplace/src/view/chat/message_menu_parts.dart'; + +export 'package:fireplace/src/view/chat/message_menu_parts.dart'; /// A lifted message and its actions share an edge, even near the keyboard. class MessageActionsOverlay extends StatelessWidget { @@ -16,6 +19,9 @@ class MessageActionsOverlay extends StatelessWidget { this.reactions = const [], this.gesture, this.preview, + this.controls, + this.picker, + this.pickerTop = double.infinity, }); final Rect anchor; @@ -26,11 +32,21 @@ class MessageActionsOverlay extends StatelessWidget { final ValueChanged onSelected; final Widget? preview; + /// Fades the reaction bar and action list out while the message stays, for + /// the emoji picker that replaces them. Null keeps them visible. + final Animation? controls; + + /// How far the emoji picker has opened (0 to 1) and the top edge it rests + /// at. A message it would cover rises to stay readable above it. + final Animation? picker; + final double pickerTop; + @override Widget build(BuildContext context) { final media = MediaQuery.of(context); final theme = Theme.of(context); final tokens = theme.extension(); + final panel = tokens?.panel ?? theme.colorScheme.surface; final targets = { for (final a in [...reactions, ...actions]) a: GlobalKey(), }; @@ -58,73 +74,89 @@ class MessageActionsOverlay extends StatelessWidget { if (chosen != null && context.mounted) onSelected(chosen); }; } + final right = anchor.center.dx > media.size.width / 2; + final shown = controls ?? const AlwaysStoppedAnimation(1.0); + // The list grows out of the message corner it hangs from. + final grow = CurvedAnimation(parent: animation, curve: Curves.easeOutBack); + Widget fading(Widget child) => FadeTransition( + opacity: shown, + child: AnimatedBuilder( + animation: shown, + builder: (context, child) => + IgnorePointer(ignoring: shown.value < 1, child: child), + child: child, + ), + ); return Stack( children: [ Positioned.fill( child: IgnorePointer( child: BackdropFilter( - filter: ui.ImageFilter.blur(sigmaX: 6, sigmaY: 6), - child: ColoredBox(color: Colors.black.withValues(alpha: .22)), + filter: ui.ImageFilter.blur(sigmaX: 24, sigmaY: 24), + child: ColoredBox( + color: Colors.black.withValues( + alpha: theme.brightness == Brightness.dark ? .4 : .2, + ), + ), ), ), ), CustomMultiChildLayout( delegate: _MessageMenuLayout( anchor, + // A message too low for its menu slides up; one with room stays. + CurvedAnimation(parent: animation, curve: Curves.easeOutCubic), + picker ?? const AlwaysStoppedAnimation(0), + pickerTop, EdgeInsets.fromLTRB( - media.padding.left + 12, - media.padding.top + 12, - media.padding.right + 12, - math.max(media.padding.bottom, media.viewInsets.bottom) + 12, + media.padding.left + MessageMenuMetrics.edge, + media.padding.top + MessageMenuMetrics.edge, + media.padding.right + MessageMenuMetrics.edge, + math.max( + math.max(media.padding.bottom, media.viewInsets.bottom), + MessageMenuMetrics.edge, + ), ), ), children: [ if (reactions.isNotEmpty) LayoutId( id: _Part.reactions, - child: KeyedSubtree( - key: reactionKey, - child: Material( - key: const Key('messageReactionBar'), - elevation: 8, - color: tokens?.panel ?? theme.colorScheme.surface, - borderRadius: BorderRadius.circular(32), - clipBehavior: Clip.antiAlias, - child: SingleChildScrollView( - scrollDirection: Axis.horizontal, - padding: const EdgeInsets.all(4), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - for (final reaction in reactions) - MessageActionHighlight( - key: targets[reaction], - action: reaction, - gesture: gesture, - child: IconButton( - key: ValueKey('messageAction-${reaction.id}'), - tooltip: reaction.label, - isSelected: reaction.selected, - style: IconButton.styleFrom( - fixedSize: const Size(48, 48), - padding: EdgeInsets.zero, - tapTargetSize: - MaterialTapTargetSize.shrinkWrap, - backgroundColor: reaction.selected - ? tokens?.selectedRow - : null, + child: fading( + KeyedSubtree( + key: reactionKey, + child: Material( + key: const Key('messageReactionBar'), + elevation: 8, + color: panel, + shape: const StadiumBorder(), + clipBehavior: Clip.antiAlias, + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric( + horizontal: MessageMenuMetrics.barPadding, + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + for (final (index, reaction) in reactions.indexed) + ReactionEntrance( + animation: animation, + index: index, + count: reactions.length, + child: MessageActionHighlight( + key: targets[reaction], + action: reaction, + gesture: gesture, + lift: true, + child: ReactionBarButton( + reaction: reaction, + onPressed: () => onSelected(reaction), + ), ), - onPressed: () => onSelected(reaction), - icon: reaction.emoji == null - ? Icon(reaction.icon) - : Text( - reaction.emoji!, - textScaler: TextScaler.noScaling, - style: const TextStyle(fontSize: 28), - ), ), - ), - ], + ], + ), ), ), ), @@ -144,81 +176,44 @@ class MessageActionsOverlay extends StatelessWidget { ), LayoutId( id: _Part.menu, - child: ScaleTransition( - scale: Tween(begin: .8, end: 1.0).animate( - CurvedAnimation(parent: animation, curve: Curves.easeOutBack), - ), - alignment: anchor.center.dx > media.size.width / 2 - ? Alignment.topRight - : Alignment.topLeft, - child: KeyedSubtree( - key: panelKey, - child: Material( - key: const Key('messageActionsPanel'), - elevation: 12, - color: tokens?.panel ?? theme.colorScheme.surface, - borderRadius: BorderRadius.circular(24), - clipBehavior: Clip.antiAlias, - child: SingleChildScrollView( - padding: const EdgeInsets.symmetric(vertical: 6), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - for (final (index, action) in actions.indexed) ...[ - if (index != 0) - Divider( - height: 1, - thickness: .5, - color: tokens?.separator, - ), - MessageActionHighlight( - key: targets[action], - action: action, - gesture: gesture, - child: TextButton( - key: ValueKey('messageAction-${action.id}'), - autofocus: index == 0, - onPressed: () => onSelected(action), - style: ButtonStyle( - shape: const WidgetStatePropertyAll( - RoundedRectangleBorder(), - ), - minimumSize: const WidgetStatePropertyAll( - Size.fromHeight(48), - ), - padding: const WidgetStatePropertyAll( - EdgeInsets.symmetric( - horizontal: 18, - vertical: 12, - ), - ), - foregroundColor: WidgetStatePropertyAll( - action.destructive - ? tokens?.danger ?? - theme.colorScheme.error - : tokens?.text ?? - theme.colorScheme.onSurface, - ), + child: fading( + ScaleTransition( + scale: Tween(begin: .2, end: 1.0).animate(grow), + alignment: right ? Alignment.topRight : Alignment.topLeft, + child: KeyedSubtree( + key: panelKey, + child: Material( + key: const Key('messageActionsPanel'), + elevation: 12, + color: panel, + borderRadius: BorderRadius.circular( + MessageMenuMetrics.menuRadius, + ), + clipBehavior: Clip.antiAlias, + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + for (final (index, action) in actions.indexed) ...[ + if (index != 0) + Divider( + height: 1 / 3, + thickness: 1 / 3, + color: tokens?.separator, ), - child: Row( - children: [ - Expanded( - child: Text( - action.label, - style: const TextStyle( - fontSize: 17, - fontWeight: FontWeight.w400, - ), - ), - ), - const SizedBox(width: 12), - Icon(action.icon, size: 22), - ], + MessageActionHighlight( + key: targets[action], + action: action, + gesture: gesture, + child: MessageMenuRow( + action: action, + autofocus: index == 0, + onPressed: () => onSelected(action), ), ), - ), + ], ], - ], + ), ), ), ), @@ -232,37 +227,22 @@ class MessageActionsOverlay extends StatelessWidget { } } -class MessageActionHighlight extends StatelessWidget { - const MessageActionHighlight({ - super.key, - required this.action, - required this.gesture, - required this.child, - }); - final MessageAction action; - final MessageMenuGesture? gesture; - final Widget child; - @override - Widget build(BuildContext context) { - if (gesture == null) return child; - return ValueListenableBuilder( - valueListenable: gesture!.highlighted, - builder: (context, highlighted, child) => ColoredBox( - color: identical(highlighted, action) - ? Theme.of(context).colorScheme.onSurface.withValues(alpha: .1) - : Colors.transparent, - child: child, - ), - child: child, - ); - } -} - enum _Part { preview, menu, reactions } class _MessageMenuLayout extends MultiChildLayoutDelegate { - _MessageMenuLayout(this.anchor, this.insets); + _MessageMenuLayout( + this.anchor, + this.shift, + this.picker, + this.pickerTop, + this.insets, + ) : super(relayout: Listenable.merge([shift, picker])); + final Animation picker; + final double pickerTop; final Rect anchor; + + /// 0 keeps the message where it was held; 1 is where everything fits. + final Animation shift; final EdgeInsets insets; @override @@ -273,28 +253,30 @@ class _MessageMenuLayout extends MultiChildLayoutDelegate { math.max(insets.left, size.width - insets.right), math.max(insets.top, size.height - insets.bottom), ); + const gap = MessageMenuMetrics.gap; final hasPreview = hasChild(_Part.preview); final hasReactions = hasChild(_Part.reactions); final reactionSize = hasReactions ? layoutChild( _Part.reactions, BoxConstraints( - maxWidth: math.min(344, area.width), - maxHeight: math.min(56, area.height), + maxWidth: area.width, + minHeight: math.min(MessageMenuMetrics.barHeight, area.height), + maxHeight: math.min(MessageMenuMetrics.barHeight, area.height), ), ) : Size.zero; - final reactionHeight = hasReactions ? reactionSize.height + 8 : 0.0; + final reactionHeight = hasReactions ? reactionSize.height + gap : 0.0; + final menuWidth = math.min(MessageMenuMetrics.menuWidth, area.width); final menu = layoutChild( _Part.menu, BoxConstraints( - minWidth: math.min(250, area.width), - maxWidth: math.min(250, area.width), + minWidth: menuWidth, + maxWidth: menuWidth, maxHeight: math.max(0, area.height - reactionHeight) * (hasPreview ? .7 : 1), ), ); - const gap = 8.0; final preview = hasPreview ? layoutChild( _Part.preview, @@ -312,16 +294,26 @@ class _MessageMenuLayout extends MultiChildLayoutDelegate { .clamp(area.left, math.max(area.left, area.right - width)); final height = reactionHeight + preview.height + (hasPreview ? gap : 0) + menu.height; - final top = (anchor.top - reactionHeight) + final held = anchor.top - reactionHeight; + final fits = held .clamp(area.top, math.max(area.top, area.bottom - height)) .toDouble(); + final top = held + (fits - held) * shift.value; if (hasReactions) { positionChild(_Part.reactions, Offset(x(reactionSize.width), top)); } if (hasPreview) { + final inMenu = top + reactionHeight; + final abovePicker = math.max( + area.top, + math.min(inMenu, pickerTop - gap - preview.height), + ); positionChild( _Part.preview, - Offset(x(preview.width), top + reactionHeight), + Offset( + x(preview.width), + inMenu + (abovePicker - inMenu) * picker.value, + ), ); } positionChild( @@ -335,5 +327,7 @@ class _MessageMenuLayout extends MultiChildLayoutDelegate { @override bool shouldRelayout(_MessageMenuLayout oldDelegate) => - anchor != oldDelegate.anchor || insets != oldDelegate.insets; + anchor != oldDelegate.anchor || + insets != oldDelegate.insets || + pickerTop != oldDelegate.pickerTop; } diff --git a/lib/src/view/chat/message_menu.dart b/lib/src/view/chat/message_menu.dart index 3c94fee..01468b5 100644 --- a/lib/src/view/chat/message_menu.dart +++ b/lib/src/view/chat/message_menu.dart @@ -1,5 +1,6 @@ import 'package:fireplace/src/model/chat/message_compose_target.dart'; import 'package:fireplace/src/model/notifications/chat_activity.dart'; +import 'package:flutter/cupertino.dart' show CupertinoIcons; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -88,14 +89,20 @@ List messageMenuActions( label: 'More reactions', icon: Icons.add, reaction: true, - onSelected: () => run(() => showReactionPicker(context, ref, message)), + recentEmoji: _recentReactions(ref, chatId(), owner?.uid), + onEmoji: (emoji) => run( + () => ref + .read(messageControlsProvider(chatId()).notifier) + .react(message.id, emoji), + ), + onSelected: () {}, ), ], if (canShare && message.status == MessageStatus.ok) MessageAction( id: 'reply', label: 'Reply', - icon: Icons.reply_rounded, + icon: CupertinoIcons.arrowshape_turn_up_left, onSelected: () => run( () => ref .read(messageComposeProvider(chatId()).notifier) @@ -111,35 +118,35 @@ List messageMenuActions( MessageAction( id: 'edit', label: 'Edit', - icon: Icons.edit_outlined, + icon: CupertinoIcons.pencil, onSelected: () => run( () => ref.read(messageComposeProvider(chatId()).notifier).edit(message), ), ), - if (canShare) - MessageAction( - id: 'copy', - label: 'Copy', - icon: Icons.copy_outlined, - onSelected: () => run( - () => _copyMessage(message, isMounted: isMounted, notice: notice), - ), - ), if (canShare && !messageTooLong(message.body) && message.status == MessageStatus.ok) MessageAction( id: 'forward', label: 'Forward', - icon: Icons.forward_outlined, + icon: CupertinoIcons.arrowshape_turn_up_right, onSelected: () => run(() => forwardMessage(context, message, name)), ), + if (canShare) + MessageAction( + id: 'copy', + label: 'Copy', + icon: CupertinoIcons.doc_on_doc, + onSelected: () => run( + () => _copyMessage(message, isMounted: isMounted, notice: notice), + ), + ), if (canShare) MessageAction( id: 'selectText', label: 'Select text', - icon: Icons.text_fields_outlined, + icon: CupertinoIcons.textformat, onSelected: () => run( () => showSelectTextSheet( context, @@ -166,7 +173,7 @@ List messageMenuActions( MessageAction( id: 'select', label: 'Select', - icon: Icons.check_circle_outline, + icon: CupertinoIcons.checkmark_circle, onSelected: () => run( () => ref .read(messageSelectionProvider(chatId()).notifier) @@ -182,7 +189,7 @@ List messageMenuActions( MessageAction( id: 'deleteEveryone', label: 'Delete for everyone', - icon: Icons.delete_outline, + icon: CupertinoIcons.trash, destructive: true, onSelected: () => run(() => confirmRemoteDelete(context, ref, message)), ), @@ -194,7 +201,7 @@ List messageMenuActions( MessageAction( id: 'report', label: 'Report', - icon: Icons.flag_outlined, + icon: CupertinoIcons.flag, destructive: true, onSelected: () => run( () => contactAction(() async { @@ -226,3 +233,17 @@ Future _copyMessage( if (isMounted()) notice('Could not copy this message. Try again.'); } } + +/// This person's reactions in the conversation, newest message first. Read +/// from the decrypted history already in memory; nothing extra is stored. +List _recentReactions(WidgetRef ref, String chatId, String? uid) { + if (uid == null) return const []; + final messages = ref.read(messagesProvider(chatId)).value ?? const []; + final recent = []; + for (final message in messages.reversed) { + final emoji = message.reactions[uid]?.emoji; + if (emoji != null && !recent.contains(emoji)) recent.add(emoji); + if (recent.length == 16) break; + } + return recent; +} diff --git a/lib/src/view/chat/message_menu_parts.dart b/lib/src/view/chat/message_menu_parts.dart new file mode 100644 index 0000000..f5e962a --- /dev/null +++ b/lib/src/view/chat/message_menu_parts.dart @@ -0,0 +1,219 @@ +import 'dart:math' as math; + +import 'package:flutter/cupertino.dart' show CupertinoIcons; +import 'package:flutter/material.dart'; +import 'package:fireplace/src/styles/design_tokens.dart'; +import 'package:fireplace/src/view/chat/message_actions.dart'; + +/// The measured geometry of the held-message menu, in logical pixels. +abstract final class MessageMenuMetrics { + /// Distance kept from the screen's side edges and from the safe area. + static const double edge = 16; + + /// Space between the reaction bar, the message and the action list. + static const double gap = 12; + static const double barHeight = 56; + static const double barPadding = 6; + static const double reactionCell = 44; + static const double reactionEmoji = 28; + static const double menuWidth = 250; + static const double menuRadius = 13; + static const double rowHeight = 50; + static const double rowLabel = 22; + static const double rowIcon = 22; + static const double rowPadding = 16; +} + +/// One quick reaction, or the round button that opens every emoji. +class ReactionBarButton extends StatelessWidget { + const ReactionBarButton({ + super.key, + required this.reaction, + required this.onPressed, + }); + final MessageAction reaction; + final VoidCallback onPressed; + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final tokens = theme.extension(); + final more = reaction.emoji == null; + return IconButton( + key: ValueKey('messageAction-${reaction.id}'), + tooltip: reaction.label, + isSelected: reaction.selected, + style: IconButton.styleFrom( + fixedSize: const Size( + MessageMenuMetrics.reactionCell, + MessageMenuMetrics.barHeight, + ), + minimumSize: Size.zero, + padding: EdgeInsets.zero, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + // The fill is drawn below at the measured size, not the tap size. + backgroundColor: Colors.transparent, + shape: const RoundedRectangleBorder(), + ), + onPressed: onPressed, + icon: Container( + width: MessageMenuMetrics.reactionCell, + height: MessageMenuMetrics.reactionCell, + alignment: Alignment.center, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: more || reaction.selected + ? tokens?.separator ?? theme.colorScheme.surfaceContainerHighest + : null, + ), + child: more + ? Icon( + CupertinoIcons.ellipsis, + size: 24, + color: tokens?.text ?? theme.colorScheme.onSurface, + ) + : Text( + reaction.emoji!, + textScaler: TextScaler.noScaling, + style: const TextStyle( + fontSize: MessageMenuMetrics.reactionEmoji, + height: 1, + ), + ), + ), + ); + } +} + +/// A label with its icon at the trailing edge. +class MessageMenuRow extends StatelessWidget { + const MessageMenuRow({ + super.key, + required this.action, + required this.onPressed, + this.autofocus = false, + }); + final MessageAction action; + final VoidCallback onPressed; + final bool autofocus; + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final tokens = theme.extension(); + return TextButton( + key: ValueKey('messageAction-${action.id}'), + autofocus: autofocus, + onPressed: onPressed, + style: ButtonStyle( + shape: const WidgetStatePropertyAll(RoundedRectangleBorder()), + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + minimumSize: const WidgetStatePropertyAll( + Size.fromHeight(MessageMenuMetrics.rowHeight - 1 / 3), + ), + padding: const WidgetStatePropertyAll( + EdgeInsets.symmetric( + horizontal: MessageMenuMetrics.rowPadding, + vertical: 8, + ), + ), + foregroundColor: WidgetStatePropertyAll( + action.destructive + ? tokens?.danger ?? theme.colorScheme.error + : tokens?.text ?? theme.colorScheme.onSurface, + ), + ), + child: Row( + children: [ + Expanded( + child: Text( + action.label, + style: const TextStyle( + fontSize: MessageMenuMetrics.rowLabel, + height: 1.2, + fontWeight: FontWeight.w400, + letterSpacing: 0, + ), + ), + ), + const SizedBox(width: 12), + Icon(action.icon, size: MessageMenuMetrics.rowIcon), + ], + ), + ); + } +} + +/// Reactions arrive one after another, left to right, as the bar opens. +class ReactionEntrance extends StatelessWidget { + const ReactionEntrance({ + super.key, + required this.animation, + required this.index, + required this.count, + required this.child, + }); + final Animation animation; + final int index, count; + final Widget child; + @override + Widget build(BuildContext context) { + final start = .5 * index / math.max(1, count); + final curve = CurvedAnimation( + parent: animation, + curve: Interval(start, math.min(1, start + .5), curve: Curves.easeOut), + ); + return FadeTransition( + opacity: curve, + child: SlideTransition( + position: Tween( + begin: const Offset(0, .35), + end: Offset.zero, + ).animate(curve), + child: child, + ), + ); + } +} + +class MessageActionHighlight extends StatelessWidget { + const MessageActionHighlight({ + super.key, + required this.action, + required this.gesture, + required this.child, + this.lift = false, + }); + final MessageAction action; + final MessageMenuGesture? gesture; + final Widget child; + + /// Reactions grow and rise under the finger; rows only tint. + final bool lift; + @override + Widget build(BuildContext context) { + if (gesture == null) return child; + return ValueListenableBuilder( + valueListenable: gesture!.highlighted, + builder: (context, highlighted, child) { + final on = identical(highlighted, action); + if (lift) { + return AnimatedSlide( + offset: on ? const Offset(0, -.4) : Offset.zero, + duration: const Duration(milliseconds: 150), + child: AnimatedScale( + scale: on ? 1.5 : 1, + duration: const Duration(milliseconds: 150), + child: child, + ), + ); + } + return ColoredBox( + color: on + ? Theme.of(context).colorScheme.onSurface.withValues(alpha: .1) + : Colors.transparent, + child: child, + ); + }, + child: child, + ); + } +} diff --git a/lib/src/view/chat/message_reactions.dart b/lib/src/view/chat/message_reactions.dart index 6fa4fa1..a652feb 100644 --- a/lib/src/view/chat/message_reactions.dart +++ b/lib/src/view/chat/message_reactions.dart @@ -2,164 +2,12 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:fireplace/src/app/providers.dart'; import 'package:fireplace/src/db/local_messages.dart'; -import 'package:fireplace/src/db/message_mutation.dart'; import 'package:fireplace/src/model/chat/message_controls.dart'; import 'package:fireplace/src/model/notifications/chat_activity.dart'; import 'package:fireplace/src/styles/design_tokens.dart'; import 'package:fireplace/src/widgets/dialog.dart'; const quickReactions = ['โค๏ธ', '๐Ÿ‘', '๐Ÿ‘Ž', '๐Ÿ˜‚', '๐Ÿ˜ฎ', '๐Ÿ˜ข']; -const reactionPalette = [ - ...quickReactions, - '๐Ÿฅฐ', - '๐Ÿ˜', - '๐Ÿ˜˜', - '๐Ÿ˜Š', - '๐Ÿ˜', - '๐Ÿคฃ', - '๐Ÿ˜…', - '๐Ÿฅฒ', - '๐Ÿ˜ญ', - '๐Ÿ˜ก', - '๐Ÿค”', - '๐Ÿ™„', - '๐Ÿ˜ด', - '๐Ÿคฏ', - '๐Ÿฅณ', - '๐Ÿ˜Ž', - '๐Ÿซก', - '๐Ÿค—', - '๐Ÿ™', - '๐Ÿ‘', - '๐Ÿ™Œ', - '๐Ÿค', - '๐Ÿ’ช', - '๐Ÿ‘€', - '๐ŸŽ‰', - '๐Ÿ”ฅ', - 'โœจ', - '๐Ÿ’ฏ', - 'โœ…', - 'โŒ', - '๐Ÿ’”', - '๐Ÿงก', - '๐Ÿ’›', - '๐Ÿ’š', - '๐Ÿ’™', - '๐Ÿ’œ', - 'โ˜•', - '๐ŸŒป', - '๐Ÿˆ', - '๐Ÿ•', -]; - -Future showReactionPicker( - BuildContext context, - WidgetRef ref, - LocalMessage message, -) async { - final owner = ref.read(appSessionProvider).value; - final emoji = await showModalBottomSheet( - context: context, - isScrollControlled: true, - useSafeArea: true, - showDragHandle: true, - builder: (_) => const ReactionPicker(), - ); - if (!context.mounted || - emoji == null || - !identical(ref.read(appSessionProvider).value, owner)) { - return; - } - await ref - .read(messageControlsProvider(message.chatId).notifier) - .react(message.id, emoji); -} - -class ReactionPicker extends StatefulWidget { - const ReactionPicker({super.key}); - @override - State createState() => _ReactionPickerState(); -} - -class _ReactionPickerState extends State { - final _text = TextEditingController(); - @override - void dispose() { - _text.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) => Padding( - padding: EdgeInsets.fromLTRB( - 16, - 0, - 16, - MediaQuery.viewInsetsOf(context).bottom + 16, - ), - child: SizedBox( - height: MediaQuery.sizeOf(context).height * .48, - child: Column( - children: [ - Text( - 'React to message', - style: Theme.of(context).textTheme.titleMedium, - ), - const SizedBox(height: 12), - Expanded( - child: SingleChildScrollView( - child: Wrap( - spacing: 4, - runSpacing: 4, - children: [ - for (final emoji in reactionPalette) - SizedBox.square( - dimension: 48, - child: TextButton( - key: ValueKey('pickReaction-$emoji'), - onPressed: () => Navigator.pop(context, emoji), - style: TextButton.styleFrom(padding: EdgeInsets.zero), - child: Text( - emoji, - textScaler: TextScaler.noScaling, - style: const TextStyle(fontSize: 26), - ), - ), - ), - ], - ), - ), - ), - Row( - children: [ - Expanded( - child: TextField( - key: const Key('customReaction'), - controller: _text, - maxLength: 64, - decoration: const InputDecoration( - hintText: 'Choose an emoji from your keyboard', - counterText: '', - ), - onChanged: (_) => setState(() {}), - ), - ), - IconButton( - tooltip: 'Use emoji', - icon: const Icon(Icons.check), - onPressed: validReactionEmoji(_text.text) - ? () => Navigator.pop(context, _text.text) - : null, - ), - ], - ), - ], - ), - ), - ); -} - Future confirmRemoteDelete( BuildContext context, WidgetRef ref, diff --git a/lib/src/view/chat/widgets/message_bubble.dart b/lib/src/view/chat/widgets/message_bubble.dart index 56147d3..99a0f5a 100644 --- a/lib/src/view/chat/widgets/message_bubble.dart +++ b/lib/src/view/chat/widgets/message_bubble.dart @@ -167,6 +167,12 @@ class _MessageBubbleState extends ConsumerState { final base = mine ? colors.outgoing.outgoing : colors.incoming.incoming(Theme.of(context).brightness); + final quoted = message.quote != null && !message.remotelyDeleted; + // With a quote the bubble's own padding shrinks to frame the panel, so + // the text keeps its usual distance from the edge with this. + final textInset = quoted + ? const EdgeInsets.symmetric(horizontal: 6) + : EdgeInsets.zero; final bubbleRadius = BorderRadius.only( topLeft: const Radius.circular(20), topRight: const Radius.circular(20), @@ -246,10 +252,9 @@ class _MessageBubbleState extends ConsumerState { .toDouble(), ), margin: const EdgeInsets.symmetric(vertical: 4), - padding: const EdgeInsets.symmetric( - horizontal: 14, - vertical: 8, - ), + padding: quoted + ? const EdgeInsets.fromLTRB(8, 8, 8, 8) + : const EdgeInsets.symmetric(horizontal: 14, vertical: 8), decoration: BoxDecoration( color: _held ? Color.alphaBlend(const Color(0x22000000), base) @@ -275,139 +280,158 @@ class _MessageBubbleState extends ConsumerState { : widget.actions) CustomSemanticsAction(label: a.label): a.onSelected, }, - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - if (message.quote != null && !message.remotelyDeleted) - MessageQuotePreview( - key: ValueKey('messageQuote-${message.id}'), - quote: message.quote!, - foreground: fg, - author: - message.quote!.senderUid == - ref.watch( - appSessionProvider.select( - (s) => s.value?.uid, - ), - ) - ? 'You' - : widget.senderName ?? 'Them', - onTap: widget.preview - ? null - : widget.selecting - ? widget.onToggleSelection - : () => widget.onQuote?.call( - message.quote!.targetId, + // A reply is as wide as the wider of its quote and its + // own text; the quote panel then spans that width. + child: _HugWidth( + enabled: quoted, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: quoted + ? CrossAxisAlignment.stretch + : CrossAxisAlignment.end, + children: [ + if (quoted) + MessageQuotePreview( + key: ValueKey('messageQuote-${message.id}'), + quote: message.quote!, + foreground: fg, + bubble: base, + outgoing: mine, + author: + message.quote!.senderUid == + ref.watch( + appSessionProvider.select( + (s) => s.value?.uid, + ), + ) + ? 'You' + : widget.senderName ?? 'Them', + onTap: widget.preview + ? null + : widget.selecting + ? widget.onToggleSelection + : () => widget.onQuote?.call( + message.quote!.targetId, + ), + ), + if (quoted) const SizedBox(height: 5), + if (bad) + Padding( + padding: const EdgeInsets.only(bottom: 4), + child: Wrap( + crossAxisAlignment: WrapCrossAlignment.center, + spacing: 6, + children: [ + Icon(Icons.lock_outline, size: 16, color: fg), + Text( + 'Unreadable message', + style: TextStyle( + color: fg, + fontWeight: FontWeight.w600, + ), ), - ), - if (bad) + ], + ), + ), Padding( - padding: const EdgeInsets.only(bottom: 4), - child: Wrap( - crossAxisAlignment: WrapCrossAlignment.center, - spacing: 6, - children: [ - Icon(Icons.lock_outline, size: 16, color: fg), - Text( - 'Unreadable message', - style: TextStyle( + padding: textInset, + child: Align( + widthFactor: quoted ? null : 1, + alignment: AlignmentDirectional.centerStart, + child: Text.rich( + formatted.span( + TextStyle( color: fg, - fontWeight: FontWeight.w600, + fontSize: 17, + height: 1.3, + fontStyle: bad || message.remotelyDeleted + ? FontStyle.italic + : FontStyle.normal, ), ), - ], - ), - ), - Align( - widthFactor: message.quote == null ? 1 : null, - alignment: AlignmentDirectional.centerStart, - child: Text.rich( - formatted.span( - TextStyle( - color: fg, - fontSize: 17, - height: 1.3, - fontStyle: bad || message.remotelyDeleted - ? FontStyle.italic - : FontStyle.normal, + semanticsLabel: formatted.plain, ), ), - semanticsLabel: formatted.plain, ), - ), - if (oversize) - TextButton( - key: ValueKey('showAll-${message.id}'), - onPressed: widget.selecting - ? widget.onToggleSelection - : () => setState(() => _showAll = !_showAll), - child: Text( - _showAll ? 'Show less' : 'Show all', - style: TextStyle( - color: fg, - decoration: TextDecoration.underline, + if (oversize) + TextButton( + key: ValueKey('showAll-${message.id}'), + onPressed: widget.selecting + ? widget.onToggleSelection + : () => setState(() => _showAll = !_showAll), + child: Text( + _showAll ? 'Show less' : 'Show all', + style: TextStyle( + color: fg, + decoration: TextDecoration.underline, + ), ), ), - ), - const SizedBox(height: 2), - Wrap( - crossAxisAlignment: WrapCrossAlignment.center, - spacing: 4, - children: [ - if (message.edits.isNotEmpty && - !message.remotelyDeleted) - Semantics( - button: true, - label: 'View edit history', - child: GestureDetector( - key: ValueKey('edited-${message.id}'), - behavior: HitTestBehavior.opaque, - onTap: widget.preview - ? null - : widget.selecting - ? widget.onToggleSelection - : () => showModalBottomSheet( - context: context, - useSafeArea: true, - showDragHandle: true, - builder: (_) => MessageEditHistory( - chatId: message.chatId, - messageId: message.id, - ownerUid: - ref - .read(appSessionProvider) - .value - ?.uid ?? - '', + const SizedBox(height: 2), + Padding( + padding: textInset, + child: Wrap( + alignment: WrapAlignment.end, + crossAxisAlignment: WrapCrossAlignment.center, + spacing: 4, + children: [ + if (message.edits.isNotEmpty && + !message.remotelyDeleted) + Semantics( + button: true, + label: 'View edit history', + child: GestureDetector( + key: ValueKey('edited-${message.id}'), + behavior: HitTestBehavior.opaque, + onTap: widget.preview + ? null + : widget.selecting + ? widget.onToggleSelection + : () => showModalBottomSheet( + context: context, + useSafeArea: true, + showDragHandle: true, + builder: (_) => MessageEditHistory( + chatId: message.chatId, + messageId: message.id, + ownerUid: + ref + .read( + appSessionProvider, + ) + .value + ?.uid ?? + '', + ), + ), + child: Padding( + padding: const EdgeInsets.symmetric( + vertical: 4, + ), + child: Text( + 'Edited', + style: TextStyle( + color: mine ? fg : t.secondaryText, + fontSize: 11, + height: 1.3, ), ), - child: Padding( - padding: const EdgeInsets.symmetric( - vertical: 4, - ), - child: Text( - 'Edited', - style: TextStyle( - color: mine ? fg : t.secondaryText, - fontSize: 11, - height: 1.3, ), ), ), + Text( + time, + style: TextStyle( + color: mine ? fg : t.secondaryText, + fontSize: 11, + height: 1.3, + ), ), - ), - Text( - time, - style: TextStyle( - color: mine ? fg : t.secondaryText, - fontSize: 11, - height: 1.3, - ), + ], ), - ], - ), - ], + ), + ], + ), ), ), ), @@ -419,3 +443,14 @@ class _MessageBubbleState extends ConsumerState { ); } } + +/// Sizes a column to its widest child instead of the space offered, so that +/// children can then be stretched to that width. +class _HugWidth extends StatelessWidget { + const _HugWidth({required this.enabled, required this.child}); + final bool enabled; + final Widget child; + @override + Widget build(BuildContext context) => + enabled ? IntrinsicWidth(child: child) : child; +} diff --git a/lib/src/view/chat/widgets/message_quote_preview.dart b/lib/src/view/chat/widgets/message_quote_preview.dart index 3dff2c3..e11e00b 100644 --- a/lib/src/view/chat/widgets/message_quote_preview.dart +++ b/lib/src/view/chat/widgets/message_quote_preview.dart @@ -3,62 +3,113 @@ import 'package:fireplace/src/db/message_quote.dart'; import 'package:fireplace/src/model/chat/message_format.dart'; import 'package:fireplace/src/styles/design_tokens.dart'; +/// The quoted message inside a reply: a tinted panel with a stripe at its +/// leading edge, the author's name and up to two lines of the original. +/// +/// It is as wide as its own text needs and no wider, so a short reply to a +/// short message stays a small bubble; the bubble stretches it to match a +/// longer reply. class MessageQuotePreview extends StatelessWidget { const MessageQuotePreview({ super.key, required this.quote, required this.author, this.foreground, + this.bubble, + this.outgoing = false, this.onTap, }); final MessageQuote quote; final String author; final Color? foreground; + + /// The colour of the bubble this sits in. Null outside a bubble, such as + /// above the composer. + final Color? bubble; + + /// Whether that bubble is one's own message. + final bool outgoing; final VoidCallback? onTap; + static const double stripe = 4; + static const double radius = 10; + @override Widget build(BuildContext context) { final tokens = FireplaceUiTokens.of(context); - final color = foreground ?? tokens.text; + final onBubble = foreground ?? tokens.text; + final base = bubble; + // In one's own coloured bubble the panel is a pale wash of that colour + // with dark text; in a received bubble it is a slightly stronger tone of + // the bubble with the bubble's own text colour. + final washed = base != null && outgoing; + final panel = base == null + ? onBubble.withValues(alpha: .08) + : washed + ? Color.alphaBlend(Colors.white.withValues(alpha: .45), base) + : Color.alphaBlend(onBubble.withValues(alpha: .14), base); + final ink = washed ? FireplaceUiTokens.light.text : onBubble; + final line = base == null + ? tokens.accent + : washed + ? Colors.white.withValues(alpha: .85) + : onBubble.withValues(alpha: .45); return Semantics( button: onTap != null, label: 'Quoted message', child: GestureDetector( behavior: HitTestBehavior.opaque, onTap: onTap, - child: Container( - margin: const EdgeInsets.only(bottom: 6), - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), - decoration: BoxDecoration( - color: color.withValues(alpha: .08), - border: Border( - left: BorderSide(color: foreground ?? tokens.accent, width: 3), - ), - borderRadius: BorderRadius.circular(6), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Text( - author, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle( - color: color, - fontSize: 13, - fontWeight: FontWeight.w600, - ), - ), - Text( - quote.unavailable - ? 'Original message unavailable' - : displayMessage(quote.body).plain, - maxLines: 2, - overflow: TextOverflow.ellipsis, - style: TextStyle(color: color, fontSize: 14, height: 1.2), + child: ClipRRect( + borderRadius: BorderRadius.circular(radius), + child: ColoredBox( + color: panel, + child: IntrinsicHeight( + child: Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: MainAxisSize.min, + children: [ + ColoredBox( + color: line, + child: const SizedBox(width: stripe), + ), + Flexible( + child: Padding( + padding: const EdgeInsets.fromLTRB(8, 6, 8, 6), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + author, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: ink, + fontSize: 13, + height: 1.3, + fontWeight: FontWeight.w600, + ), + ), + Text( + quote.unavailable + ? 'Original message unavailable' + : displayMessage(quote.body).plain, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: ink, + fontSize: 15, + height: 1.3, + ), + ), + ], + ), + ), + ), + ], ), - ], + ), ), ), ), diff --git a/lib/src/view/chat_list/chat_list_screen.dart b/lib/src/view/chat_list/chat_list_screen.dart index 5499ed7..6bbe4da 100644 --- a/lib/src/view/chat_list/chat_list_screen.dart +++ b/lib/src/view/chat_list/chat_list_screen.dart @@ -251,7 +251,13 @@ class _ChatListScreenState extends ConsumerState { sorted.length + requestOffset + (sorted.isEmpty ? 1 : 0), - separatorBuilder: (_, _) => Divider(height: 1, indent: 72), + // A hairline from the text edge to the row's end. + separatorBuilder: (_, _) => const Divider( + height: 1 / 3, + thickness: 1 / 3, + indent: ChatTile.textInset, + endIndent: ChatTile.edge, + ), itemBuilder: (_, i) { if (requests.isNotEmpty && i == 0) { return ListTile( diff --git a/lib/src/view/chat_list/chat_list_stamp.dart b/lib/src/view/chat_list/chat_list_stamp.dart new file mode 100644 index 0000000..448a63c --- /dev/null +++ b/lib/src/view/chat_list/chat_list_stamp.dart @@ -0,0 +1,25 @@ +import 'package:flutter/material.dart'; + +const _weekdays = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']; + +/// The short label beside a conversation: a time today, a weekday within the +/// last week, otherwise a numeric date (with the year only when it differs). +String chatListStamp( + DateTime sentAt, + DateTime now, + MaterialLocalizations localizations, +) { + final at = sentAt.toLocal(); + final today = now.toLocal(); + final days = DateUtils.dateOnly(today) + .difference(DateUtils.dateOnly(at)) + .inDays; + if (days <= 0) { + return localizations.formatTimeOfDay(TimeOfDay.fromDateTime(at)); + } + if (days < 7) return _weekdays[at.weekday - 1]; + final date = localizations.formatCompactDate(at); + if (at.year != today.year) return date; + // Drop the year and the separator beside it, keeping the locale's order. + return date.replaceFirst(RegExp(r'(^\d{4}\D)|(\D\d{4}$)'), ''); +} diff --git a/lib/src/view/chat_list/widgets/chat_tile.dart b/lib/src/view/chat_list/widgets/chat_tile.dart index a422270..f3429c2 100644 --- a/lib/src/view/chat_list/widgets/chat_tile.dart +++ b/lib/src/view/chat_list/widgets/chat_tile.dart @@ -1,17 +1,75 @@ import 'package:flutter/material.dart'; +import 'package:flutter/semantics.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:fireplace/src/app/providers.dart'; import 'package:fireplace/src/model/chat/chat_service.dart'; import 'package:fireplace/src/db/local_messages.dart'; +import 'package:fireplace/src/model/chat/message_controls.dart'; import 'package:fireplace/src/model/notifications/chat_activity.dart'; import 'package:fireplace/src/model/chat/message_format.dart'; +import 'package:fireplace/src/styles/design_tokens.dart'; +import 'package:fireplace/src/view/chat_list/chat_list_stamp.dart'; import 'package:fireplace/src/widgets/avatar.dart'; +/// One conversation row: avatar, name and date on the first line, then a +/// two-line preview with the unread count beside its second line. class ChatTile extends ConsumerWidget { const ChatTile({super.key, required this.chat, required this.onOpen}); final ChatSummary chat; final VoidCallback onOpen; + /// Row geometry shared with the list separator. + static const double avatarSize = 60; + static const double edge = 16; + static const double textInset = edge + avatarSize + 10; + + Future _toggleMute( + BuildContext context, + WidgetRef ref, + bool muted, + ) async { + try { + await ref.read(chatActivityProvider.notifier).mute(chat.chatId, !muted); + } catch (_) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Could not save the mute preference. Try again.'), + ), + ); + } + } + } + + Future _showActions( + BuildContext context, + WidgetRef ref, + bool muted, [ + Offset? at, + ]) async { + final box = context.findRenderObject()! as RenderBox; + final overlay = + Overlay.of(context).context.findRenderObject()! as RenderBox; + final point = at ?? box.localToGlobal(box.size.center(Offset.zero)); + final chosen = await showMenu( + context: context, + position: RelativeRect.fromRect( + point & Size.zero, + Offset.zero & overlay.size, + ), + items: [ + PopupMenuItem( + value: 'mute', + child: Text(muted ? 'Unmute chat' : 'Mute chat'), + ), + ], + ); + if (chosen == 'mute' && context.mounted) { + await _toggleMute(context, ref, muted); + } + } + @override Widget build(BuildContext context, WidgetRef ref) { final name = ref.watch(peerUsernameProvider(chat.peerUid)).value ?? 'โ€ฆ'; @@ -27,106 +85,231 @@ class ChatTile extends ConsumerWidget { } final msgs = ref.watch(messagesProvider(chat.chatId)).value ?? const []; final last = msgs.isEmpty ? null : msgs.last; - return ListTile( - key: ValueKey('conversation-${chat.chatId}'), - contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6), - leading: PersonAvatar(name: name), - title: Row( - children: [ - Expanded( - child: Text( - name, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle(fontWeight: FontWeight.w700), - ), - ), - if (last != null) - Flexible( - child: Padding( - padding: const EdgeInsets.only(left: 8), - child: Text( - MaterialLocalizations.of(context).formatTimeOfDay( - TimeOfDay.fromDateTime(last.sentAt.toLocal()), + final t = FireplaceUiTokens.of(context); + final scheme = Theme.of(context).colorScheme; + final preview = held + ? 'Messages on hold โ€” review security code' + : last == null + ? (!chat.accepted && chat.initiator != null + ? 'Waiting for them to accept' + : 'Encrypted chat') + // "(not confirmed)" comes first so the ellipsis never hides it. + : (last.outgoing + ? (last.status == MessageStatus.unconfirmed + ? 'You (not confirmed): ' + : 'You: ') + : '') + + (last.status == MessageStatus.undecryptable + ? 'Unreadable encrypted message' + : messagePreview(last.body)); + final scaler = MediaQuery.textScalerOf(context); + final line = scaler.scale(20) * 1.35; + var pressedAt = Offset.zero; + return CallbackShortcuts( + bindings: { + const SingleActivator(LogicalKeyboardKey.contextMenu): () => + _showActions(context, ref, muted), + const SingleActivator(LogicalKeyboardKey.f10, shift: true): () => + _showActions(context, ref, muted), + }, + child: Semantics( + customSemanticsActions: { + CustomSemanticsAction( + label: muted ? 'Unmute chat' : 'Mute chat', + ): () => + _toggleMute(context, ref, muted), + }, + child: InkWell( + key: ValueKey('conversation-${chat.chatId}'), + onTap: onOpen, + onTapDown: (details) => pressedAt = details.globalPosition, + onLongPress: () => _showActions(context, ref, muted, pressedAt), + onSecondaryTapUp: (details) => + _showActions(context, ref, muted, details.globalPosition), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: edge), + child: Row( + children: [ + PersonAvatar(name: name, size: avatarSize), + const SizedBox(width: 10), + Expanded( + child: ConstrainedBox( + constraints: const BoxConstraints(minHeight: 104), + child: Padding( + padding: const EdgeInsets.only(top: 10.5, bottom: 13.5), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // The date's smaller type must not stretch the line. + SizedBox( + height: scaler.scale(26), + child: Row( + crossAxisAlignment: CrossAxisAlignment.baseline, + textBaseline: TextBaseline.alphabetic, + children: [ + Expanded( + child: Row( + children: [ + Flexible( + child: Text( + name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 21, + height: 26 / 21, + fontWeight: FontWeight.w600, + color: t.text, + ), + ), + ), + if (held) + Padding( + padding: const EdgeInsets.only( + left: 6, + ), + child: Icon( + Icons.gpp_maybe, + key: const Key( + 'tileIdentityWarning', + ), + size: scaler.scale(18), + color: scheme.error, + semanticLabel: + 'Security code changed', + ), + ), + if (muted) + Padding( + padding: const EdgeInsets.only( + left: 6, + ), + child: Icon( + Icons.notifications_off, + key: ValueKey( + 'muted-${chat.chatId}', + ), + size: scaler.scale(16), + color: t.secondaryText, + semanticLabel: 'Muted', + ), + ), + ], + ), + ), + if (last != null) + Padding( + padding: const EdgeInsets.only(left: 8), + child: ConstrainedBox( + constraints: BoxConstraints( + maxWidth: + MediaQuery.sizeOf(context).width * + .45, + ), + child: Text( + chatListStamp( + last.sentAt, + ref.watch( + messageControlNowProvider, + )(), + MaterialLocalizations.of(context), + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 19, + height: 26 / 19, + fontWeight: FontWeight.w400, + color: t.secondaryText, + ), + ), + ), + ), + ], + ), + ), + ConstrainedBox( + // The count sits beside the second preview line + // even when the preview is shorter. + constraints: BoxConstraints(minHeight: line * 2), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Text( + preview, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 20, + height: 1.35, + fontWeight: FontWeight.w400, + color: t.secondaryText, + ), + ), + ), + if (unread > 0) + Padding( + padding: EdgeInsets.only( + left: 8, + top: line + (line - scaler.scale(26)) / 2, + ), + child: UnreadCount( + key: ValueKey('unread-${chat.chatId}'), + count: unread, + muted: muted, + ), + ), + ], + ), + ), + ], + ), + ), ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.labelSmall, ), - ), + ], ), - ], - ), - subtitle: Text( - held - ? 'Messages on hold โ€” review security code' - : last == null - ? (!chat.accepted && chat.initiator != null - ? 'Waiting for them to accept' - : 'Encrypted chat') - // "(not confirmed)" comes first so the ellipsis never hides it. - : (last.outgoing - ? (last.status == MessageStatus.unconfirmed - ? 'You (not confirmed): ' - : 'You: ') - : '') + - (last.status == MessageStatus.undecryptable - ? 'Unreadable encrypted message' - : messagePreview(last.body)), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - trailing: PopupMenuButton( - key: ValueKey('conversationMenu-${chat.chatId}'), - tooltip: muted ? 'Muted conversation actions' : 'Conversation actions', - icon: Badge( - isLabelVisible: unread > 0, - backgroundColor: muted - ? Theme.of(context).colorScheme.surfaceContainerHighest - : Theme.of(context).colorScheme.primary, - textColor: muted - ? Theme.of(context).colorScheme.onSurface - : Colors.white, - label: Semantics( - label: '$unread unread messages', - child: Text(unreadLabel(unread)), - ), - key: unread > 0 ? ValueKey('unread-${chat.chatId}') : null, - child: Icon( - key: held ? const Key('tileIdentityWarning') : null, - held - ? Icons.gpp_maybe - : muted - ? Icons.notifications_off_outlined - : Icons.more_horiz, - color: held ? Theme.of(context).colorScheme.error : null, ), ), - onSelected: (_) async { - try { - await ref - .read(chatActivityProvider.notifier) - .mute(chat.chatId, !muted); - } catch (_) { - if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text( - 'Could not save the mute preference. Try again.', - ), - ), - ); - } - } - }, - itemBuilder: (_) => [ - PopupMenuItem( - value: 'mute', - child: Text(muted ? 'Unmute chat' : 'Mute chat'), + ), + ); + } +} + +/// A filled count: a circle for one digit, a pill for more. +class UnreadCount extends StatelessWidget { + const UnreadCount({super.key, required this.count, this.muted = false}); + final int count; + final bool muted; + @override + Widget build(BuildContext context) { + final t = FireplaceUiTokens.of(context); + final size = MediaQuery.textScalerOf(context).scale(26); + return Semantics( + label: '$count unread messages', + excludeSemantics: true, + child: Container( + height: size, + constraints: BoxConstraints(minWidth: size), + padding: const EdgeInsets.symmetric(horizontal: 5), + alignment: Alignment.center, + decoration: ShapeDecoration( + shape: const StadiumBorder(), + color: muted ? t.separator : t.accent, + ), + child: Text( + unreadLabel(count), + maxLines: 1, + style: TextStyle( + fontSize: 16, + height: 1, + fontWeight: FontWeight.w600, + color: muted ? t.text : Colors.white, + leadingDistribution: TextLeadingDistribution.even, ), - ], + ), ), - onTap: onOpen, ); } } diff --git a/lib/src/widgets/avatar.dart b/lib/src/widgets/avatar.dart index 28a71d6..6fa76dd 100644 --- a/lib/src/widgets/avatar.dart +++ b/lib/src/widgets/avatar.dart @@ -1,6 +1,21 @@ import 'package:flutter/material.dart'; import 'package:fireplace/src/styles/design_tokens.dart'; +/// Up to two initials: one per word, so a single-word username shows one. +String avatarInitials(String name) { + final words = name + .trim() + .split(RegExp(r'\s+')) + .where((word) => word.isNotEmpty) + .toList(); + if (words.isEmpty) return '?'; + return [ + words.first.characters.first, + if (words.length > 1) words.last.characters.first, + ].join().toUpperCase(); +} + +/// Initials on the brand orange. There are no profile photos. class PersonAvatar extends StatelessWidget { const PersonAvatar({super.key, required this.name, this.size = 44}); final String name; @@ -8,20 +23,35 @@ class PersonAvatar extends StatelessWidget { @override Widget build(BuildContext context) { final t = FireplaceUiTokens.of(context); - final value = name.trim(); + final initials = avatarInitials(name); return ExcludeSemantics( child: Container( width: size, height: size, alignment: Alignment.center, decoration: BoxDecoration( - color: t.selectedRow, - border: Border.all(color: t.separator), shape: BoxShape.circle, + // Lighter at the top, like a lit surface; white stays above 3:1. + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Color.lerp(t.accent, Colors.white, .18)!, t.accent], + ), ), child: Text( - value.isEmpty ? '?' : value.characters.first.toUpperCase(), - style: TextStyle(color: t.text, fontWeight: FontWeight.w700), + initials, + maxLines: 1, + softWrap: false, + // The initial is part of the picture and scales with the circle. + textScaler: TextScaler.noScaling, + textAlign: TextAlign.center, + style: TextStyle( + color: Colors.white, + fontSize: size * (initials.characters.length > 1 ? .37 : .43), + height: 1, + fontWeight: FontWeight.w700, + leadingDistribution: TextLeadingDistribution.even, + ), ), ), ); diff --git a/scripts/gen_emoji_catalog.py b/scripts/gen_emoji_catalog.py new file mode 100644 index 0000000..1f72ba9 --- /dev/null +++ b/scripts/gen_emoji_catalog.py @@ -0,0 +1,105 @@ +"""Generate lib/src/model/chat/emoji_catalog_data.dart. + +python3 scripts/gen_emoji_catalog.py EMOJI_TEST ANNOTATIONS DERIVED OUT + +EMOJI_TEST is Unicode's emoji-test.txt (15.0). ANNOTATIONS and DERIVED are +CLDR's common/annotations/en.xml and common/annotationsDerived/en.xml (42). +The app never downloads these; the generated table ships with it so emoji +search works offline. Skin-tone variants are left out, and so is anything +`validReactionEmoji` (lib/src/db/message_mutation.dart) would reject: the +pattern below must stay the same as that one. +""" + +import re +import sys +import xml.etree.ElementTree as ET + +PICTOGRAPH = ( + "[\U0001F300-\U0001F3FA\U0001F400-\U0001FAFFโ˜€-โžฟ" + "ยฉยฎโ€ผโ‰โ„ขโ„นโ†”-โ‡ฟโŒ€-โฟ" + "โฌ€-โฏฟใ€ฐใ€ฝใŠ—ใŠ™]" +) +MODIFIER = "๏ธ?[\U0001F3FB-\U0001F3FF]?" +VALID = re.compile( + "^(?:" + PICTOGRAPH + MODIFIER + "(?:โ€" + PICTOGRAPH + MODIFIER + ")*" + "|[\U0001F1E6-\U0001F1FF]{2}|[0-9#*]๏ธ?โƒฃ)$" +) +GROUPS = { + "Smileys & Emotion": 0, + "People & Body": 0, + "Animals & Nature": 1, + "Food & Drink": 2, + "Activities": 3, + "Travel & Places": 4, + "Objects": 5, + "Symbols": 6, + "Flags": 7, +} +NAMES = [ + "Smileys & People", + "Animals & Nature", + "Food & Drink", + "Activities", + "Travel & Places", + "Objects", + "Symbols", + "Flags", +] +HEADER = """// GENERATED by scripts/gen_emoji_catalog.py from Unicode emoji-test.txt 15.0 and +// CLDR 42 English annotations. Do not edit by hand. Skin-tone variants and +// sequences that reaction validation does not accept are left out. +// Data (c) Unicode, Inc., used under the Unicode License v3; see +// docs/legal/THIRD_PARTY_NOTICES.md. +// dart format off + +/// One emoji per line: the emoji, its name, then search keywords, separated +/// by tabs. A line starting with '#' names the category that follows. +const emojiCatalogData = r''' +""" + + +def main() -> None: + emoji_test, annotations, derived, out = sys.argv[1:5] + keywords: dict[str, list[str]] = {} + for path in (annotations, derived): + for node in ET.parse(path).getroot().iter("annotation"): + if node.get("type") != "tts": + keywords[node.get("cp")] = [k.strip() for k in node.text.split("|")] + categories: list[list[str]] = [[] for _ in NAMES] + group = None + dropped = 0 + for line in open(emoji_test, encoding="utf-8"): + if line.startswith("# group:"): + group = line.split(":", 1)[1].strip() + continue + if line.startswith("#") or "; fully-qualified" not in line: + continue + if group not in GROUPS: + continue + emoji = "".join(chr(int(c, 16)) for c in line.split(";")[0].split()) + name = re.search(r"# \S+ E\d+\.\d+ (.+)$", line.strip()).group(1).lower() + if "skin tone" in name: + continue + if not VALID.match(emoji) or len(emoji.encode("utf-16-le")) // 2 > 64: + dropped += 1 + continue + words = [name] + for word in keywords.get(emoji) or keywords.get(emoji.replace("๏ธ", "")) or []: + word = word.lower() + if word not in words: + words.append(word) + row = "\t".join([emoji] + words) + if "'''" in row: + raise SystemExit(f"cannot embed: {row}") + categories[GROUPS[group]].append(row) + with open(out, "w", encoding="utf-8", newline="\n") as file: + file.write(HEADER) + for name, rows in zip(NAMES, categories): + file.write("#" + name + "\n") + file.write("\n".join(rows) + "\n") + file.write("''';\n") + print([len(rows) for rows in categories], "dropped", dropped, file=sys.stderr) + + +if __name__ == "__main__": + main() diff --git a/scripts/render/README.md b/scripts/render/README.md index e48aa2b..7d73579 100644 --- a/scripts/render/README.md +++ b/scripts/render/README.md @@ -32,3 +32,14 @@ bundled with the app or committed. Keep the font and environment identical when comparing render hashes. The manifest records whether this fallback was supplied. The seven-feature continuation adds formatted messages, muted/unread controls, the back-arrow total, forwarding, global search and message location, name-only/text foreground cards, and device linking at narrow enlarged-text and landscape sizes. Searches use the real isolate scanner over fictional local history; asynchronous search is fully settled before capturing. Every state still has paired light/dark renders. + +## Comparing with a reference screenshot + +The `reference-*` states render at 375 x 812 points with a notched phone's safe +areas and three pixels per point, the size of the owner's reference screenshots. +`python3 scripts/render/compare_reference.py RENDER REFERENCE OUT_PREFIX` writes +a side-by-side image and an even blend of the two; `--render-top` and +`--reference-top` align crops of different heights. Reference screenshots can +contain other people's names and messages: keep them and the comparison images +out of Git unless the owner has cleared them. The renderer also loads the +Cupertino icon font from the resolved `cupertino_icons` package. diff --git a/scripts/render/catalog_case.dart b/scripts/render/catalog_case.dart index bdf84d0..8821c7d 100644 --- a/scripts/render/catalog_case.dart +++ b/scripts/render/catalog_case.dart @@ -21,6 +21,8 @@ class CatalogCase { this.act, this.overrideAuth = true, this.notices = false, + this.padding = EdgeInsets.zero, + this.pixelRatio = 2, }); final void Function(UiFixture)? prepare; final List Function(UiFixture)? services; @@ -32,4 +34,8 @@ class CatalogCase { final CatalogInteraction? interaction; final Size size; final double textScale, keyboardInset; + + /// Safe-area insets and capture density, for comparing with a device image. + final EdgeInsets padding; + final double pixelRatio; } diff --git a/scripts/render/compare_reference.py b/scripts/render/compare_reference.py new file mode 100644 index 0000000..71f66e9 --- /dev/null +++ b/scripts/render/compare_reference.py @@ -0,0 +1,47 @@ +"""Compare a render with a reference screenshot of the same pixel width. + +python3 scripts/render/compare_reference.py RENDER REFERENCE OUT_PREFIX \ + [--render-top PX] [--reference-top PX] [--height PX] + +Writes OUT_PREFIX-side.png (reference left, render right) and +OUT_PREFIX-overlay.png (an even blend). Reference screenshots may contain +other people's names and messages: keep them and these outputs out of Git +unless the owner has cleared them. +""" + +import argparse + +from PIL import Image + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("render") + parser.add_argument("reference") + parser.add_argument("out") + parser.add_argument("--render-top", type=int, default=0) + parser.add_argument("--reference-top", type=int, default=0) + parser.add_argument("--height", type=int, default=0) + args = parser.parse_args() + render = Image.open(args.render).convert("RGB") + reference = Image.open(args.reference).convert("RGB") + if render.width != reference.width: + raise SystemExit( + f"widths differ: render {render.width}, reference {reference.width}" + ) + height = args.height or min( + render.height - args.render_top, reference.height - args.reference_top + ) + ours = render.crop((0, args.render_top, render.width, args.render_top + height)) + theirs = reference.crop( + (0, args.reference_top, reference.width, args.reference_top + height) + ) + side = Image.new("RGB", (ours.width * 2 + 12, height), "white") + side.paste(theirs, (0, 0)) + side.paste(ours, (ours.width + 12, 0)) + side.save(f"{args.out}-side.png") + Image.blend(theirs, ours, 0.5).save(f"{args.out}-overlay.png") + + +if __name__ == "__main__": + main() diff --git a/scripts/render/reference_cases.dart b/scripts/render/reference_cases.dart new file mode 100644 index 0000000..b82ce79 --- /dev/null +++ b/scripts/render/reference_cases.dart @@ -0,0 +1,168 @@ +// States rendered at the size of the owner's reference screenshots (a +// 375 x 812 point phone at three pixels per point) so they can be overlaid. +// All names and messages here are fictional. +import 'package:fireplace/fireplace_services.dart'; +import 'package:fireplace/src/db/message_quote.dart'; +import 'package:fireplace/src/view/chat/chat_screen.dart'; +import 'package:fireplace/src/view/chat_list/chat_list_screen.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../../test/support/ui_fixture.dart'; +import 'catalog_case.dart'; + +const referenceSize = Size(375, 812); +const referencePadding = EdgeInsets.only(top: 44, bottom: 34); + +LocalMessage _incoming(String peer, String id, Duration age, String body) => + LocalMessage( + id: id, + chatId: 'alice_$peer', + senderUid: peer, + senderDevice: 'example-device', + outgoing: false, + sentAt: fixtureTime.subtract(age), + body: body, + status: MessageStatus.ok, + ); + +/// Five rows shaped like the reference: two-line previews, one unread count, +/// a one-line preview and a conversation without messages. +void seedReferenceChats(UiFixture f) { + const people = { + 'robin': 'Robin Vale', + 'tabitha': 'Tabitha Frost', + 'dana': 'Dana', + 'marlow': 'marlow_echo', + 'juniper': 'Juniper Hale', + }; + const ages = { + 'robin': Duration(days: 1), + 'tabitha': Duration(days: 2), + 'dana': Duration(days: 3), + 'marlow': Duration(days: 14), + 'juniper': Duration(days: 15), + }; + f.chat.summaries = [ + for (final peer in people.keys) + ChatSummary('alice_$peer', peer, fixtureTime.subtract(ages[peer]!)), + ]; + for (final entry in people.entries) { + f.names[entry.key] = Future.value(entry.value); + } + for (final (peer, body) in [ + ('robin', 'Hope your evening feels relaxed and calm.'), + ( + 'tabitha', + 'Thought I would write and say something kind. What is your take on it?', + ), + ('dana', 'garden-plan-final.png'), + ('juniper', 'Just a warm note from me. Are you free between tasks?'), + ]) { + f.chat.store.add(_incoming(peer, 'ref-$peer', ages[peer]!, body)); + } +} + +/// Replies of different lengths: a short one stays small, a long one grows. +void seedReferenceReplies(UiFixture f) { + LocalMessage reply( + String id, + int minutes, + bool outgoing, + String body, + String quotedFrom, + String quoted, + ) => LocalMessage( + id: id, + chatId: 'alice_fred', + senderUid: outgoing ? 'alice' : 'fred', + senderDevice: 'example-device', + outgoing: outgoing, + sentAt: fixtureTime.add(Duration(minutes: minutes)), + body: body, + status: MessageStatus.ok, + quote: MessageQuote(targetId: '5', senderUid: quotedFrom, body: quoted), + ); + f.chat.store + ..add( + reply( + 'reply-long', + 1, + true, + 'A picnic blanket, lemonade, maybe some shortbread', + 'fred', + 'What would you even suggest I bring then', + ), + ) + ..add( + reply( + 'reply-in', + 2, + false, + 'Lovely', + 'alice', + 'A picnic blanket, lemonade, maybe some shortbread', + ), + ) + ..add(reply('reply-short', 3, true, 'test', 'alice', 'test')); +} + +final referenceCases = [ + CatalogCase( + 'reference-replies', + const ChatScreen(chatId: 'alice_fred'), + size: referenceSize, + padding: referencePadding, + pixelRatio: 3, + prepare: seedReferenceReplies, + ), + CatalogCase( + 'reference-chat-list', + const ChatListScreen(), + size: referenceSize, + padding: referencePadding, + pixelRatio: 3, + prepare: seedReferenceChats, + act: (t, f) async { + // Everything except the first conversation has been read. + for (final peer in ['tabitha', 'dana', 'juniper']) { + await f.session.chatPreferences.markSeen('alice_$peer', ['ref-$peer']); + } + await settleUi(t); + }, + ), + for (final mine in [false, true]) + CatalogCase( + 'reference-message-menu-${mine ? 'outgoing' : 'incoming'}', + const ChatScreen(chatId: 'alice_fred'), + size: referenceSize, + padding: referencePadding, + pixelRatio: 3, + act: (t, f) async { + await t.longPress( + find.byKey(ValueKey('messageBubble-${mine ? '4' : '5'}')), + ); + await t.pumpAndSettle(); + }, + ), + for (final state in ['picker', 'picker-search']) + CatalogCase( + 'reference-emoji-$state', + const ChatScreen(chatId: 'alice_fred'), + size: referenceSize, + padding: referencePadding, + pixelRatio: 3, + act: (t, f) async { + // An earlier reaction of one's own fills the Recents row. + await f.chat.react('alice_fred', '3', '๐Ÿ’ฏ'); + await settleUi(t); + await t.longPress(find.byKey(const ValueKey('messageBubble-5'))); + await t.pumpAndSettle(); + await t.tap(find.byKey(const ValueKey('messageAction-moreReactions'))); + await t.pumpAndSettle(); + if (state == 'picker') return; + await t.enterText(find.byKey(const Key('emojiSearch')), 'heart'); + await t.pumpAndSettle(); + }, + ), +]; diff --git a/scripts/render/render_screens_test.dart b/scripts/render/render_screens_test.dart index 53a8b53..b53322e 100644 --- a/scripts/render/render_screens_test.dart +++ b/scripts/render/render_screens_test.dart @@ -5,6 +5,7 @@ import 'dart:async'; import 'catalog_case.dart'; import 'reply_edit_cases.dart'; +import 'reference_cases.dart'; import 'message_surface_cases.dart'; import 'dart:convert'; @@ -91,6 +92,21 @@ Future loadFonts() async { ), )) .load(); + final cupertino = packages.cast().firstWhere( + (p) => p['name'] == 'cupertino_icons', + ); + await (FontLoader('packages/cupertino_icons/CupertinoIcons')..addFont( + Future.value( + ByteData.sublistView( + File.fromUri( + config.uri + .resolve('${cupertino['rootUri']}/') + .resolve('assets/CupertinoIcons.ttf'), + ).readAsBytesSync(), + ), + ), + )) + .load(); await (FontLoader('MaterialIcons')..addFont( Future.value( ByteData.sublistView( @@ -112,6 +128,7 @@ void main() { final cases = [ ...messageSurfaceCases, ...replyEditCases, + ...referenceCases, for (final mine in [false, true]) CatalogCase( mine ? 'message-actions-outgoing' : 'message-actions-incoming', @@ -645,6 +662,8 @@ void main() { data: MediaQuery.of(context).copyWith( textScaler: TextScaler.linear(spec.textScale), viewInsets: EdgeInsets.only(bottom: spec.keyboardInset), + padding: spec.padding, + viewPadding: spec.padding, ), child: spec.notices ? InAppNoticeHost( @@ -692,7 +711,7 @@ void main() { boundary.currentContext!.findRenderObject() as RenderRepaintBoundary; final png = await tester.runAsync(() async { - final image = await renderer.toImage(pixelRatio: 2); + final image = await renderer.toImage(pixelRatio: spec.pixelRatio); try { return (await image.toByteData(format: ui.ImageByteFormat.png))! .buffer diff --git a/test/model/chat/emoji_catalog_test.dart b/test/model/chat/emoji_catalog_test.dart new file mode 100644 index 0000000..f4aae5e --- /dev/null +++ b/test/model/chat/emoji_catalog_test.dart @@ -0,0 +1,82 @@ +import 'package:fireplace/src/db/message_mutation.dart'; +import 'package:fireplace/src/model/chat/emoji_catalog.dart'; +import 'package:fireplace/src/view/chat/message_reactions.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('the catalog has the eight picker categories, in order', () { + expect(emojiCategories.map((c) => c.name), [ + 'Smileys & People', + 'Animals & Nature', + 'Food & Drink', + 'Activities', + 'Travel & Places', + 'Objects', + 'Symbols', + 'Flags', + ]); + expect(emojiCategories.first.entries.first.emoji, '๐Ÿ˜€'); + expect(emojiCategories.every((c) => c.entries.length > 50), isTrue); + }); + + test('every emoji is one the encrypted reaction format accepts', () { + // The picker must never offer something a peer would reject, and the + // reaction format itself is unchanged. + final all = [for (final c in emojiCategories) ...c.entries]; + expect(all.length, greaterThan(1500)); + expect(all.where((e) => !validReactionEmoji(e.emoji)), isEmpty); + expect(all.map((e) => e.emoji).toSet().length, all.length); + expect(all.where((e) => e.name.contains('skin tone')), isEmpty); + expect(all.where((e) => e.name.isEmpty), isEmpty); + }); + + test('the quick reactions are all in the catalog', () { + final all = { + for (final c in emojiCategories) + for (final e in c.entries) e.emoji, + }; + expect(quickReactions.where((e) => !all.contains(e)), isEmpty); + }); + + test('the keycap that starts with # is an emoji, not a category', () { + final symbols = emojiCategories.firstWhere((c) => c.name == 'Symbols'); + expect(symbols.entries.any((e) => e.emoji.startsWith('#')), isTrue); + }); + + group('searchEmoji', () { + List find(String query) => + searchEmoji(query).map((e) => e.emoji).toList(); + + test('puts an exact name, then names starting with the query, first', () { + // "fire" itself, then "firefighter", which only starts with it. + expect(find('fire').take(2), ['๐Ÿ”ฅ', '๐Ÿง‘โ€๐Ÿš’']); + expect(find('red heart').first, 'โค๏ธ'); + expect(find('thumbs up').first, '๐Ÿ‘'); + expect(find('fire').first, '๐Ÿ”ฅ'); + }); + test('matches every word in any order, ignoring case and spaces', () { + expect(find(' HEART red '), contains('โค๏ธ')); + expect(find('face tears joy'), contains('๐Ÿ˜‚')); + }); + test('matches keywords as well as names', () { + // "hundred" is a keyword of the ๐Ÿ’ฏ emoji, not part of its name. + expect(find('hundred'), contains('๐Ÿ’ฏ')); + expect(find('laugh'), contains('๐Ÿ˜‚')); + }); + test('returns nothing for blank or unknown text', () { + expect(find(''), isEmpty); + expect(find(' '), isEmpty); + expect(find('zzzzqqqq'), isEmpty); + }); + test('is bounded for long input and broad matches', () { + expect(searchEmoji('a').length, lessThanOrEqualTo(120)); + expect(searchEmoji('face', limit: 5).length, 5); + final long = 'heart ${'x' * 5000}'; + final watch = Stopwatch()..start(); + expect(searchEmoji(long), isA>()); + // Not a speed threshold: only that the cap keeps this from scaling + // with the input. + expect(watch.elapsed, lessThan(const Duration(seconds: 5))); + }, tags: 'timing'); + }); +} diff --git a/test/view/chat/chat_features_test.dart b/test/view/chat/chat_features_test.dart index b9a2651..3d358d3 100644 --- a/test/view/chat/chat_features_test.dart +++ b/test/view/chat/chat_features_test.dart @@ -142,7 +142,8 @@ void main() { addTearDown(f.session.close); await mount(t, f, const ChatListScreen()); final c = container(t); - await t.tap(find.byKey(const ValueKey('conversationMenu-alice_fred'))); + // The row itself carries the actions now; there is no menu button. + await t.longPress(find.byKey(const ValueKey('conversation-alice_fred'))); await t.pumpAndSettle(); await t.tap(find.text('Mute chat')); await t.pumpAndSettle(); diff --git a/test/view/chat/emoji_picker_test.dart b/test/view/chat/emoji_picker_test.dart new file mode 100644 index 0000000..cd1d95d --- /dev/null +++ b/test/view/chat/emoji_picker_test.dart @@ -0,0 +1,341 @@ +import 'package:fireplace/src/styles/theme.dart'; +import 'package:fireplace/src/view/chat/chat_screen.dart'; +import 'package:fireplace/src/view/chat/emoji_picker.dart'; +import 'package:fireplace/src/view/chat/widgets/message_bubble.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../../support/ui_fixture.dart'; + +const phone = Size(375, 812); +const safeArea = EdgeInsets.only(top: 44, bottom: 34); + +Finder bubble(String id) => find.byKey(ValueKey('messageBubble-$id')); +Finder action(String id) => find.byKey(ValueKey('messageAction-$id')); +Finder pick(String emoji) => find.byKey(ValueKey('pickReaction-$emoji')); +final sheet = find.byKey(const Key('emojiPicker')); +final search = find.byKey(const Key('emojiSearch')); +final lifted = find.byWidgetPredicate((w) => w is MessageBubble && w.preview); + +Future openChat( + WidgetTester t, + UiFixture f, { + double keyboard = 0, + double textScale = 1, +}) async { + t.view.physicalSize = phone; + t.view.devicePixelRatio = 1; + addTearDown(t.view.reset); + addTearDown(f.session.close); + await t.pumpWidget( + ProviderScope( + overrides: f.overrides, + child: MaterialApp( + theme: fireplaceTheme(Brightness.dark), + builder: (context, child) => MediaQuery( + data: MediaQuery.of(context).copyWith( + padding: safeArea, + viewPadding: safeArea, + viewInsets: EdgeInsets.only(bottom: keyboard), + textScaler: TextScaler.linear(textScale), + ), + child: child!, + ), + home: const ChatScreen(chatId: 'alice_fred'), + ), + ), + ); + await settleUi(t); +} + +Future openPicker(WidgetTester t, {String id = '5'}) async { + await t.longPress(bubble(id)); + await t.pumpAndSettle(); + await t.tap(action('moreReactions')); + await t.pumpAndSettle(); +} + +void main() { + testWidgets('the picker replaces the bar and list and keeps the message', ( + t, + ) async { + final f = UiFixture(); + await f.seed(); + await openChat(t, f); + await openPicker(t); + + final rect = t.getRect(sheet); + expect(rect.top, closeTo(phone.height * (1 - .541), .5)); + expect(rect.bottom, phone.height); + expect(rect.width, phone.width); + // The held message stays readable above the sheet. + expect(t.getRect(lifted).bottom, lessThanOrEqualTo(rect.top - 12 + .01)); + // The bar and list have faded out and no longer take taps. + expect(action('reply').hitTestable(), findsNothing); + expect(action('react-๐Ÿ‘').hitTestable(), findsNothing); + // Handle, search field and first grid row sit at the measured offsets. + final handle = t.getRect(find.byKey(const Key('emojiPickerHandle'))); + expect(handle.size, const Size(36, 5)); + expect(handle.top - rect.top, 5); + final field = t.getRect(search); + expect(field.top - rect.top, 32); + expect(field.left, 16); + expect(field.right, phone.width - 16); + expect(field.height, closeTo(47, .5)); + final first = t.getRect(pick('๐Ÿ˜€')); + final second = t.getRect(pick('๐Ÿ˜ƒ')); + final below = t.getRect(pick('๐Ÿ˜‚')); + expect(first.size, const Size.square(38)); + expect(first.left, 16); + expect(second.left - first.left, closeTo(50.83, .01)); + expect(below.top - first.top, 48); + expect(below.left, first.left, reason: 'seven emoji in a row'); + expect(find.text('Smileys & People'), findsOneWidget); + }); + + testWidgets('choosing an emoji sends that reaction and closes everything', ( + t, + ) async { + final f = UiFixture(); + await f.seed(); + await openChat(t, f); + await openPicker(t); + await t.tap(pick('๐Ÿฅฐ')); + await t.pumpAndSettle(); + expect(f.chat.reactions, [('5', '๐Ÿฅฐ')]); + expect(sheet, findsNothing); + expect(lifted, findsNothing); + }); + + testWidgets('search filters locally by name and keyword', (t) async { + final f = UiFixture(); + await f.seed(); + // Any platform call other than text input would be a lookup elsewhere. + final calls = []; + t.binding.defaultBinaryMessenger.setMockMethodCallHandler( + SystemChannels.platform, + (call) async { + calls.add(call.method); + return null; + }, + ); + addTearDown( + () => t.binding.defaultBinaryMessenger.setMockMethodCallHandler( + SystemChannels.platform, + null, + ), + ); + await openChat(t, f); + await openPicker(t); + calls.clear(); + + await t.enterText(search, 'red heart'); + await t.pumpAndSettle(); + expect(pick('โค๏ธ'), findsOneWidget); + expect(pick('๐Ÿ˜€'), findsNothing); + expect(find.text('Smileys & People'), findsNothing); + expect(find.byKey(const Key('emojiCategories')), findsNothing); + // First result, first cell. + expect(t.getRect(pick('โค๏ธ')).left, 16); + + await t.enterText(search, 'hundred'); + await t.pumpAndSettle(); + expect(pick('๐Ÿ’ฏ'), findsOneWidget); + + await t.enterText(search, 'zzzzqqqq'); + await t.pumpAndSettle(); + expect(find.byKey(const Key('emojiSearchEmpty')), findsOneWidget); + + await t.enterText(search, ''); + await t.pumpAndSettle(); + expect(pick('๐Ÿ˜€'), findsOneWidget); + expect(find.byKey(const Key('emojiCategories')), findsOneWidget); + expect(calls.where((m) => !m.startsWith('SystemSound')), isEmpty); + + await t.enterText(search, 'thumbs up'); + await t.pumpAndSettle(); + await t.tap(pick('๐Ÿ‘')); + await t.pumpAndSettle(); + expect(f.chat.reactions, [('5', '๐Ÿ‘')]); + }); + + testWidgets('an emoji typed from the keyboard is offered as it is', ( + t, + ) async { + final f = UiFixture(); + await f.seed(); + await openChat(t, f); + await openPicker(t); + // A family sequence is valid for reactions but not listed by search. + await t.enterText(search, '๐Ÿง‘โ€๐Ÿš€'); + await t.pumpAndSettle(); + expect(pick('๐Ÿง‘โ€๐Ÿš€'), findsOneWidget); + await t.tap(pick('๐Ÿง‘โ€๐Ÿš€').first); + await t.pumpAndSettle(); + expect(f.chat.reactions, [('5', '๐Ÿง‘โ€๐Ÿš€')]); + }); + + testWidgets('searching opens the sheet to full height', (t) async { + final f = UiFixture(); + await f.seed(); + await openChat(t, f); + await openPicker(t); + final resting = t.getRect(sheet).top; + await t.tap(search); + await t.pumpAndSettle(); + expect(t.getRect(sheet).top, lessThan(resting)); + expect(t.getRect(sheet).top, closeTo(safeArea.top + 10, .5)); + }); + + testWidgets('category buttons jump to their section and follow scrolling', ( + t, + ) async { + final f = UiFixture(); + await f.seed(); + await openChat(t, f); + await openPicker(t); + Finder category(String name) => find.byKey(ValueKey('emojiCategory-$name')); + bool selected(String name) => + t.widget(category(name)).isSelected!; + expect(selected('Smileys & People'), isTrue); + // Buttons are forty points across and forty-four apart. + expect(t.getSize(category('Smileys & People')), const Size.square(40)); + expect( + t.getRect(category('Animals & Nature')).left - + t.getRect(category('Smileys & People')).left, + 44, + ); + await t.tap(category('Food & Drink')); + await t.pumpAndSettle(); + expect(find.text('Food & Drink'), findsOneWidget); + expect(pick('๐Ÿ‡'), findsOneWidget); + expect(selected('Food & Drink'), isTrue); + expect(selected('Smileys & People'), isFalse); + await t.tap(category('Smileys & People')); + await t.pumpAndSettle(); + expect(pick('๐Ÿ˜€'), findsOneWidget); + }); + + testWidgets('recent reactions come from this conversation, newest first', ( + t, + ) async { + final f = UiFixture(); + await f.seed(); + await f.chat.react('alice_fred', '1', '๐Ÿ’ฏ'); + await f.chat.react('alice_fred', '3', '๐Ÿ”ฅ'); + await openChat(t, f); + await openPicker(t); + expect(find.text('Recents'), findsOneWidget); + final fire = find.byKey(const ValueKey('recentReaction-๐Ÿ”ฅ')); + final hundred = find.byKey(const ValueKey('recentReaction-๐Ÿ’ฏ')); + expect(t.getRect(fire).left, 16); + expect(t.getRect(hundred).left, greaterThan(t.getRect(fire).left)); + expect(find.byKey(const ValueKey('emojiCategory-Recents')), findsOneWidget); + await t.tap(hundred); + await t.pumpAndSettle(); + expect(f.chat.reactions.last, ('5', '๐Ÿ’ฏ')); + }); + + testWidgets('without earlier reactions there is no Recents section', ( + t, + ) async { + final f = UiFixture(); + await f.seed(); + await openChat(t, f); + await openPicker(t); + expect(find.text('Recents'), findsNothing); + expect(find.byKey(const ValueKey('emojiCategory-Recents')), findsNothing); + }); + + testWidgets('dragging the sheet down closes without reacting', (t) async { + final f = UiFixture(); + await f.seed(); + await openChat(t, f); + await openPicker(t); + await t.drag( + find.byKey(const Key('emojiPickerHandle')), + const Offset(0, 500), + ); + await t.pumpAndSettle(); + expect(sheet, findsNothing); + expect(lifted, findsNothing); + expect(f.chat.reactions, isEmpty); + }); + + testWidgets('tapping outside or pressing Escape closes without reacting', ( + t, + ) async { + final f = UiFixture(); + await f.seed(); + await openChat(t, f); + await openPicker(t); + await t.tapAt(const Offset(350, 60)); + await t.pumpAndSettle(); + expect(sheet, findsNothing); + + await openPicker(t); + await t.sendKeyEvent(LogicalKeyboardKey.escape); + await t.pumpAndSettle(); + expect(sheet, findsNothing); + expect(f.chat.reactions, isEmpty); + }); + + testWidgets('the sheet stays above the keyboard', (t) async { + final f = UiFixture(); + await f.seed(); + await openChat(t, f, keyboard: 300); + await openPicker(t); + expect(t.getRect(sheet).bottom, phone.height - 300); + expect(t.takeException(), isNull); + }); + + testWidgets('enlarged text keeps emoji inside their cells and labels names', ( + t, + ) async { + final f = UiFixture(); + await f.seed(); + final semantics = t.ensureSemantics(); + await openChat(t, f, textScale: 2); + await openPicker(t); + expect(t.takeException(), isNull); + final cell = t.getSize(pick('๐Ÿ˜€')); + final glyph = t.getSize( + find.descendant(of: pick('๐Ÿ˜€'), matching: find.text('๐Ÿ˜€')), + ); + expect(glyph.height, lessThanOrEqualTo(cell.height)); + expect(glyph.width, lessThanOrEqualTo(cell.width)); + expect(find.bySemanticsLabel('grinning face'), findsOneWidget); + expect(t.getRect(search).height, greaterThan(47)); + semantics.dispose(); + }); + + testWidgets('the picker lays out by itself with the measured metrics', ( + t, + ) async { + String? picked; + var dismissed = false; + t.view.physicalSize = phone; + t.view.devicePixelRatio = 1; + addTearDown(t.view.reset); + await t.pumpWidget( + MaterialApp( + theme: fireplaceTheme(Brightness.light), + home: Scaffold( + body: EmojiPickerSheet( + recent: const ['๐ŸŽ‰'], + onPicked: (emoji) => picked = emoji, + onDismissed: () => dismissed = true, + ), + ), + ), + ); + await t.pumpAndSettle(); + await t.tap(find.byKey(const ValueKey('recentReaction-๐ŸŽ‰'))); + expect(picked, '๐ŸŽ‰'); + expect(dismissed, isFalse); + expect(EmojiPickerMetrics.cell, 38); + expect(EmojiPickerMetrics.rowGap, 10); + }); +} diff --git a/test/view/chat/message_menu_geometry_test.dart b/test/view/chat/message_menu_geometry_test.dart new file mode 100644 index 0000000..409d251 --- /dev/null +++ b/test/view/chat/message_menu_geometry_test.dart @@ -0,0 +1,279 @@ +import 'package:fireplace/src/styles/theme.dart'; +import 'package:fireplace/src/view/chat/chat_screen.dart'; +import 'package:fireplace/src/view/chat/message_actions_overlay.dart'; +import 'package:fireplace/src/view/chat/widgets/message_bubble.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../../support/ui_fixture.dart'; + +const phone = Size(375, 812); +const safeArea = EdgeInsets.only(top: 44, bottom: 34); + +Finder bubble(String id) => find.byKey(ValueKey('messageBubble-$id')); +Finder action(String id) => find.byKey(ValueKey('messageAction-$id')); +final lifted = find.byWidgetPredicate((w) => w is MessageBubble && w.preview); +final bar = find.byKey(const Key('messageReactionBar')); +final panel = find.byKey(const Key('messageActionsPanel')); + +Future openChat( + WidgetTester t, + UiFixture f, { + Widget? home, + bool reduceMotion = false, +}) async { + t.view.physicalSize = phone; + t.view.devicePixelRatio = 1; + addTearDown(t.view.reset); + await t.pumpWidget( + ProviderScope( + overrides: f.overrides, + child: MaterialApp( + theme: fireplaceTheme(Brightness.dark), + builder: (context, child) => MediaQuery( + data: MediaQuery.of(context).copyWith( + padding: safeArea, + viewPadding: safeArea, + disableAnimations: reduceMotion, + ), + child: child!, + ), + home: home ?? const ChatScreen(chatId: 'alice_fred'), + ), + ), + ); + await settleUi(t); +} + +/// Enough history that early messages sit high on the screen. +Future longChat() async { + final f = UiFixture(); + await f.seed(); + for (var i = 0; i < 8; i++) { + await f.chat.store.add( + message( + id: 'more-$i', + body: 'Message number $i', + outgoing: i.isOdd, + at: fixtureTime.add(Duration(minutes: i + 1)), + ), + ); + } + return f; +} + +ScrollPosition timeline(WidgetTester t) => + Scrollable.of(t.element(bubble('more-7'))).position; + +void main() { + testWidgets('bar, message and actions use the measured geometry', (t) async { + final f = UiFixture(); + await f.seed(); + addTearDown(f.session.close); + await openChat(t, f); + await t.longPress(bubble('5')); + await t.pumpAndSettle(); + + final reactions = t.getRect(bar); + final message = t.getRect(lifted); + final menu = t.getRect(panel); + expect(reactions.left, MessageMenuMetrics.edge); + expect(reactions.size, const Size(320, 56)); + expect(message.top - reactions.bottom, moreOrLessEquals(12, epsilon: 4.5)); + expect(menu.width, 250); + expect(menu.left, MessageMenuMetrics.edge); + // Seven 44-point cells with six points at each end. + final heart = t.getRect(action('react-โค๏ธ')); + final more = t.getRect(action('moreReactions')); + expect(heart.left - reactions.left, 6); + expect(heart.width, 44); + expect(reactions.right - more.right, 6); + // Rows are fifty points apart and the list ends at the safe area. + final reply = t.getRect(action('reply')); + final forward = t.getRect(action('forward')); + expect(forward.top - reply.top, moreOrLessEquals(50, epsilon: .01)); + expect(menu.bottom, phone.height - safeArea.bottom); + expect( + t.widget(panel).borderRadius, + BorderRadius.circular(MessageMenuMetrics.menuRadius), + ); + }); + + testWidgets('an outgoing message hangs its bar and actions from the right', ( + t, + ) async { + final f = UiFixture(); + await f.seed(); + addTearDown(f.session.close); + await openChat(t, f); + await t.longPress(bubble('4')); + await t.pumpAndSettle(); + expect(t.getRect(bar).right, phone.width - MessageMenuMetrics.edge); + expect(t.getRect(panel).right, phone.width - MessageMenuMetrics.edge); + }); + + testWidgets('a message with room below stays exactly where it was held', ( + t, + ) async { + final f = await longChat(); + addTearDown(f.session.close); + await openChat(t, f); + // The first message high enough for the bar above and the list below. + final target = [for (var i = 0; i < 8; i++) bubble('more-$i')].firstWhere( + (b) => + b.evaluate().isNotEmpty && + t.getRect(b).top > safeArea.top + 16 + 56 + 12 + 64, + ); + final before = t.getRect(target); + expect(before.top, greaterThan(safeArea.top + 16 + 56 + 12)); + expect(before.bottom + 12 + 350, lessThan(phone.height - safeArea.bottom)); + final offset = timeline(t).pixels; + + await t.longPress(target); + await t.pump(const Duration(milliseconds: 60)); + final early = t.getRect(lifted); + await t.pumpAndSettle(); + final settled = t.getRect(lifted); + + expect(early.top, moreOrLessEquals(settled.top, epsilon: .01)); + expect(settled.left, before.left); + expect(settled.top, moreOrLessEquals(before.top, epsilon: 4.5)); + expect(t.getRect(bar).bottom, lessThan(settled.top)); + expect(t.getRect(panel).top, greaterThan(settled.bottom)); + expect(timeline(t).pixels, offset); + expect(t.getRect(target), before); + }); + + testWidgets( + 'a message too low for its actions rises, then returns on dismissal', + (t) async { + final f = await longChat(); + addTearDown(f.session.close); + await openChat(t, f); + final target = bubble('more-7'); + final before = t.getRect(target); + final offset = timeline(t).pixels; + expect(before.bottom + 12 + 300, greaterThan(phone.height)); + + await t.longPress(target); + await t.pump(const Duration(milliseconds: 30)); + final start = t.getRect(lifted).top; + await t.pumpAndSettle(); + final raised = t.getRect(lifted); + + // It moved up, by animation, only as far as the list needs. + expect(raised.top, lessThan(start)); + expect(start, lessThanOrEqualTo(before.top + 5)); + expect(t.getRect(panel).bottom, phone.height - safeArea.bottom); + expect( + t.getRect(panel).top - raised.bottom, + moreOrLessEquals(12, epsilon: 4.5), + ); + // The conversation underneath did not scroll or move. + expect(timeline(t).pixels, offset); + expect(t.getRect(target), before); + + await t.tapAt(const Offset(350, 60)); + await t.pump(); + await t.pump(const Duration(milliseconds: 330)); + final returning = t.getRect(lifted).top; + expect(returning, greaterThan(raised.top)); + await t.pumpAndSettle(); + expect(lifted, findsNothing); + expect(timeline(t).pixels, offset); + expect(t.getRect(target), before); + }, + ); + + testWidgets('reacting to a raised message leaves the conversation in place', ( + t, + ) async { + final f = await longChat(); + addTearDown(f.session.close); + await openChat(t, f); + final offset = timeline(t).pixels; + final above = t.getRect(bubble('more-5')); + await t.longPress(bubble('more-7')); + await t.pumpAndSettle(); + await t.tap(action('react-๐Ÿ‘')); + await t.pumpAndSettle(); + expect(f.chat.reactions, [('more-7', '๐Ÿ‘')]); + expect(lifted, findsNothing); + expect(timeline(t).pixels, offset); + expect(t.getRect(bubble('more-5')).left, above.left); + }); + + testWidgets('reduced motion places the raised message without animating', ( + t, + ) async { + final f = await longChat(); + addTearDown(f.session.close); + await openChat(t, f, reduceMotion: true); + await t.longPress(bubble('more-7')); + await t.pump(); + await t.pump(); + expect(t.getRect(panel).bottom, phone.height - safeArea.bottom); + }); + + testWidgets('Back still returns to the list after the menu has closed', ( + t, + ) async { + // The menu registers a Back handler while open. Removing it must not + // tell Android that Back now leaves the app from inside a chat. + t.binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed); + final handles = []; + t.binding.defaultBinaryMessenger.setMockMethodCallHandler( + SystemChannels.platform, + (call) async { + if (call.method == 'SystemNavigator.setFrameworkHandlesBack') { + handles.add(call.arguments as bool); + } + return null; + }, + ); + addTearDown( + () => t.binding.defaultBinaryMessenger.setMockMethodCallHandler( + SystemChannels.platform, + null, + ), + ); + final f = UiFixture(); + await f.seed(); + addTearDown(f.session.close); + await openChat( + t, + f, + home: Builder( + builder: (context) => Scaffold( + body: TextButton( + key: const Key('open'), + onPressed: () => Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => const ChatScreen(chatId: 'alice_fred'), + ), + ), + child: const Text('Open'), + ), + ), + ), + ); + await t.tap(find.byKey(const Key('open'))); + await t.pumpAndSettle(); + await settleUi(t); + expect(handles.last, isTrue); + + await t.longPress(bubble('5')); + await t.pumpAndSettle(); + await t.tap(action('reply')); + await t.pumpAndSettle(); + expect(find.byType(ChatScreen), findsOneWidget); + expect(handles.last, isTrue, reason: 'still inside a chat'); + + await t.binding.handlePopRoute(); + await t.pumpAndSettle(); + expect(find.byType(ChatScreen), findsNothing); + expect(find.byKey(const Key('open')), findsOneWidget); + }, variant: TargetPlatformVariant.only(TargetPlatform.android)); +} diff --git a/test/view/chat/message_menu_test.dart b/test/view/chat/message_menu_test.dart index 7634fd3..95cfb75 100644 --- a/test/view/chat/message_menu_test.dart +++ b/test/view/chat/message_menu_test.dart @@ -22,7 +22,7 @@ void main() { false, MessageStatus.ok, '**Hello**', - ['reply', 'copy', 'forward', 'selectText', 'select', 'report'], + ['reply', 'forward', 'copy', 'selectText', 'select', 'report'], ), ( 'sent', @@ -35,8 +35,8 @@ void main() { [ 'reply', 'edit', - 'copy', 'forward', + 'copy', 'selectText', 'select', 'deleteEveryone', diff --git a/test/view/chat/message_reactions_test.dart b/test/view/chat/message_reactions_test.dart index bccf6bf..576ada0 100644 --- a/test/view/chat/message_reactions_test.dart +++ b/test/view/chat/message_reactions_test.dart @@ -64,8 +64,8 @@ void main() { await t.pumpAndSettle(); final bar = t.getRect(find.byKey(const Key('messageReactionBar'))); final more = t.getRect(action('moreReactions')); - expect(bar.contains(more.topLeft), isTrue); - expect(bar.contains(more.bottomRight), isTrue); + // The button fills the bar's height, so compare with shared edges. + expect(bar.expandToInclude(more), bar); await t.tap(action('moreReactions')); await t.pumpAndSettle(); await t.tap(find.byKey(const ValueKey('pickReaction-๐Ÿฅฐ'))); diff --git a/test/view/chat/quoted_reply_layout_test.dart b/test/view/chat/quoted_reply_layout_test.dart new file mode 100644 index 0000000..8b249f7 --- /dev/null +++ b/test/view/chat/quoted_reply_layout_test.dart @@ -0,0 +1,225 @@ +import 'package:fireplace/fireplace_services.dart'; +import 'package:fireplace/src/db/message_quote.dart'; +import 'package:fireplace/src/styles/design_tokens.dart'; +import 'package:fireplace/src/styles/theme.dart'; +import 'package:fireplace/src/view/chat/chat_screen.dart'; +import 'package:fireplace/src/view/chat/widgets/message_quote_preview.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../../support/ui_fixture.dart'; + +const phone = Size(375, 812); + +Finder bubble(String id) => find.byKey(ValueKey('messageBubble-$id')); +Finder quote(String id) => find.byKey(ValueKey('messageQuote-$id')); + +LocalMessage reply( + String id, + String body, + String quoted, { + bool outgoing = true, + String from = 'fred', + int minutes = 1, +}) => LocalMessage( + id: id, + chatId: 'alice_fred', + senderUid: outgoing ? 'alice' : 'fred', + senderDevice: 'example-device', + outgoing: outgoing, + sentAt: fixtureTime.add(Duration(minutes: minutes)), + body: body, + status: MessageStatus.ok, + quote: MessageQuote(targetId: '5', senderUid: from, body: quoted), +); + +Future openChat( + WidgetTester t, + UiFixture f, { + Brightness brightness = Brightness.dark, + double textScale = 1, +}) async { + t.view.physicalSize = phone; + t.view.devicePixelRatio = 1; + addTearDown(t.view.reset); + addTearDown(f.session.close); + await t.pumpWidget( + ProviderScope( + overrides: f.overrides, + child: MaterialApp( + theme: fireplaceTheme(brightness), + builder: (context, child) => MediaQuery( + data: MediaQuery.of(context) + .copyWith(textScaler: TextScaler.linear(textScale)), + child: child!, + ), + home: const ChatScreen(chatId: 'alice_fred'), + ), + ), + ); + await settleUi(t); +} + +void main() { + testWidgets('a short reply to a short message stays a small bubble', ( + t, + ) async { + final f = UiFixture(); + await f.seed(); + await f.chat.store.add(reply('short', 'ok', 'hi', from: 'alice')); + await openChat(t, f); + final box = t.getRect(bubble('short')); + final panel = t.getRect(quote('short')); + // Far narrower than the widest a bubble may be (80% of the row). + expect(box.width, lessThan(phone.width * .45)); + expect(box.right, phone.width - 16); + // The panel sits eight points inside the bubble and spans it. + expect(panel.left - box.left, 8); + expect(box.right - panel.right, 8); + expect(panel.top - box.top, moreOrLessEquals(8, epsilon: 4.01)); + }); + + testWidgets('the bubble grows with a longer reply and stretches the quote', ( + t, + ) async { + final f = UiFixture(); + await f.seed(); + await f.chat.store.add(reply('short', 'ok', 'hi', minutes: 1)); + await f.chat.store.add( + reply( + 'wide', + 'A noticeably longer reply than its quote', + 'hi', + minutes: 2, + ), + ); + await openChat(t, f); + final small = t.getRect(bubble('short')); + final wide = t.getRect(bubble('wide')); + expect(wide.width, greaterThan(small.width + 60)); + final panel = t.getRect(quote('wide')); + expect(panel.left - wide.left, 8); + expect(wide.right - panel.right, 8); + }); + + testWidgets('the bubble grows with a longer quote, up to the usual limit', ( + t, + ) async { + final f = UiFixture(); + await f.seed(); + await f.chat.store.add( + reply('quoted', 'ok', 'A quoted message that is fairly long', minutes: 1), + ); + await f.chat.store.add( + reply( + 'huge', + 'ok', + List.filled(30, 'A very long quoted message.').join(' '), + minutes: 2, + ), + ); + await openChat(t, f); + final limit = (phone.width - 32) * .8; + final quoted = t.getRect(bubble('quoted')); + expect(quoted.width, greaterThan(150)); + expect(quoted.width, lessThanOrEqualTo(limit + .01)); + final huge = t.getRect(bubble('huge')); + expect(huge.width, moreOrLessEquals(limit, epsilon: .01)); + // Two lines of the original at most, however long it was. + final text = t.widget( + find.descendant(of: quote('huge'), matching: find.byType(Text)).last, + ); + expect(text.maxLines, 2); + expect(text.overflow, TextOverflow.ellipsis); + expect(t.takeException(), isNull); + }); + + testWidgets('a received reply hugs its content on the left', (t) async { + final f = UiFixture(); + await f.seed(); + await f.chat.store.add( + reply('in', 'Lovely', 'See you there', outgoing: false, from: 'alice'), + ); + await openChat(t, f); + final box = t.getRect(bubble('in')); + expect(box.left, 16); + // Narrower than the widest a bubble may be. + expect(box.width, lessThan((phone.width - 32) * .8 - 20)); + expect( + find.descendant(of: quote('in'), matching: find.text('You')), + findsOneWidget, + ); + }); + + testWidgets('the panel has a four-point stripe and ten-point corners', ( + t, + ) async { + final f = UiFixture(); + await f.seed(); + await f.chat.store.add(reply('mine', 'ok', 'hi')); + await f.chat.store.add( + reply('theirs', 'ok', 'hi', outgoing: false, from: 'alice', minutes: 2), + ); + await openChat(t, f); + for (final id in ['mine', 'theirs']) { + final clip = t.widget( + find.descendant(of: quote(id), matching: find.byType(ClipRRect)), + ); + expect(clip.borderRadius, BorderRadius.circular(10)); + final stripe = find + .descendant(of: quote(id), matching: find.byType(SizedBox)) + .evaluate() + .map((e) => e.widget as SizedBox) + .where((box) => box.width == MessageQuotePreview.stripe); + expect(stripe, hasLength(1)); + } + Color ink(String id) => t + .widget( + find.descendant(of: quote(id), matching: find.byType(Text)).first, + ) + .style! + .color!; + // Dark text on the pale wash of one's own bubble; the bubble's own text + // colour on a received one. + expect(ink('mine'), FireplaceUiTokens.light.text); + expect(ink('theirs'), FireplaceUiTokens.dark.incomingText); + }); + + testWidgets('enlarged text keeps the reply inside the bubble limit', ( + t, + ) async { + final f = UiFixture(); + await f.seed(); + await f.chat.store.add( + reply( + 'big', + 'A longer reply than usual', + 'A quoted message of some length', + ), + ); + await openChat(t, f, textScale: 2); + expect(t.takeException(), isNull); + expect( + t.getRect(bubble('big')).width, + lessThanOrEqualTo((phone.width - 32) * .8 + .01), + ); + }); + + testWidgets('a message without a quote is unchanged', (t) async { + final f = UiFixture(); + await f.seed(); + await openChat(t, f); + final box = t.getRect(bubble('5')); + final text = t.getRect( + find.descendant( + of: bubble('5'), + matching: find.text( + 'Around two? There is no rush.', + findRichText: true, + ), + ), + ); + expect(text.left - box.left, 14); + }); +} diff --git a/test/view/chat_list/chat_list_discovery_test.dart b/test/view/chat_list/chat_list_discovery_test.dart index 57fb5fd..55e9b61 100644 --- a/test/view/chat_list/chat_list_discovery_test.dart +++ b/test/view/chat_list/chat_list_discovery_test.dart @@ -75,7 +75,7 @@ void main() { await f.seed(); await pumpList(t, f); final tile = find.byKey(const ValueKey('conversation-alice_fred')); - final callback = t.widget(tile).onTap!; + final callback = t.widget(tile).onTap!; callback(); callback(); await t.pumpAndSettle(); diff --git a/test/view/chat_list/chat_tile_layout_test.dart b/test/view/chat_list/chat_tile_layout_test.dart new file mode 100644 index 0000000..21110d0 --- /dev/null +++ b/test/view/chat_list/chat_tile_layout_test.dart @@ -0,0 +1,318 @@ +import 'package:fireplace/fireplace_services.dart'; +import 'package:fireplace/src/styles/design_tokens.dart'; +import 'package:fireplace/src/styles/theme.dart'; +import 'package:fireplace/src/view/chat_list/chat_list_screen.dart'; +import 'package:fireplace/src/view/chat_list/chat_list_stamp.dart'; +import 'package:fireplace/src/view/chat_list/widgets/chat_tile.dart'; +import 'package:fireplace/src/widgets/avatar.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/semantics.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../../support/ui_fixture.dart'; + +Future pumpList( + WidgetTester t, + UiFixture f, { + double textScale = 1, +}) async { + t.view.physicalSize = const Size(375, 812); + t.view.devicePixelRatio = 1; + addTearDown(t.view.reset); + addTearDown(f.session.close); + await t.pumpWidget( + ProviderScope( + overrides: f.overrides, + child: MaterialApp( + theme: fireplaceTheme(Brightness.dark), + builder: (context, child) => MediaQuery( + data: MediaQuery.of(context) + .copyWith(textScaler: TextScaler.linear(textScale)), + child: child!, + ), + home: const ChatListScreen(), + ), + ), + ); + await settleUi(t); +} + +Finder row(String chat) => find.byKey(ValueKey('conversation-$chat')); +Finder unread(String chat) => find.byKey(ValueKey('unread-$chat')); + +void main() { + testWidgets('a row uses the measured avatar, text and separator geometry', ( + t, + ) async { + final f = UiFixture(); + await f.seed(); + await pumpList(t, f); + final tile = t.getRect(row('alice_fred')); + expect(tile.height, 104); + final avatar = t.getRect( + find.descendant( + of: row('alice_fred'), + matching: find.byType(PersonAvatar), + ), + ); + expect(avatar.size, const Size.square(60)); + expect(avatar.left, 16); + expect(avatar.center.dy, tile.center.dy); + final name = t.getRect( + find.descendant(of: row('alice_fred'), matching: find.text('fred')), + ); + expect(name.left, 86); + expect(name.top - tile.top, 10.5); + // The date ends sixteen points from the edge. + final date = t.getRect( + find.descendant(of: row('alice_fred'), matching: find.text('12:00 PM')), + ); + expect(date.right, 375 - 16); + final divider = t.widget(find.byType(Divider).first); + expect(divider.indent, ChatTile.textInset); + expect(divider.endIndent, ChatTile.edge); + expect(divider.thickness, 1 / 3); + // No menu button: the row is name, date, preview and count only. + expect( + find.descendant( + of: row('alice_fred'), + matching: find.byType(PopupMenuButton), + ), + findsNothing, + ); + }); + + testWidgets('the avatar is an initial on brand orange, never a photo', ( + t, + ) async { + final f = UiFixture(); + await f.seed(); + await pumpList(t, f); + final avatar = find.descendant( + of: row('alice_fred'), + matching: find.byType(PersonAvatar), + ); + final box = t.widget( + find.descendant(of: avatar, matching: find.byType(Container)), + ); + final decoration = box.decoration! as BoxDecoration; + expect(decoration.shape, BoxShape.circle); + expect(decoration.image, isNull); + expect( + (decoration.gradient! as LinearGradient).colors.last, + FireplaceUiTokens.dark.accent, + ); + final initial = t.widget( + find.descendant(of: avatar, matching: find.text('F')), + ); + expect(initial.style!.color, Colors.white); + expect(initial.style!.fontWeight, FontWeight.w700); + expect(initial.style!.fontSize, closeTo(26, .5)); + expect( + find.descendant(of: avatar, matching: find.byType(Image)), + findsNothing, + ); + // Centred in the circle. + expect( + t.getCenter(find.descendant(of: avatar, matching: find.text('F'))), + t.getCenter(avatar), + ); + }); + + test('initials come from the first and last word', () { + expect(avatarInitials('dana'), 'D'); + expect(avatarInitials(' Robin Vale '), 'RV'); + expect(avatarInitials('Ana Maria Lopez'), 'AL'); + expect(avatarInitials('marlow_echo'), 'M'); + expect(avatarInitials(''), '?'); + }); + + testWidgets( + 'the count comes from local history and sits by the second line', + (t) async { + final f = UiFixture(); + await f.seed(); + await pumpList(t, f); + // Three incoming messages in the local store have not been seen. + expect(unread('alice_fred'), findsOneWidget); + expect( + find.descendant(of: unread('alice_fred'), matching: find.text('3')), + findsOneWidget, + ); + final tile = t.getRect(row('alice_fred')); + final badge = t.getRect(unread('alice_fred')); + // A circle for one digit (the test font's digits are extra wide). + expect(badge.height, 26); + expect(badge.width, closeTo(26, .5)); + expect(badge.right, 375 - 16); + expect(badge.center.dy - tile.top, closeTo(76.5, .6)); + final fill = + t + .widget( + find.descendant( + of: unread('alice_fred'), + matching: find.byType(Container), + ), + ) + .decoration! + as ShapeDecoration; + expect(fill.color, FireplaceUiTokens.dark.accent); + expect(unread('alice_bob'), findsNothing); + + // One more arrives in the local store: the count follows it. + await f.chat.store.add( + message( + id: 'new', + body: 'One more', + at: fixtureTime.add(const Duration(minutes: 1)), + ), + ); + await settleUi(t); + expect( + find.descendant(of: unread('alice_fred'), matching: find.text('4')), + findsOneWidget, + ); + + // Marking them seen on this device clears it; nothing came from a server. + await f.session.chatPreferences.markSeen('alice_fred', [ + '1', + '3', + '5', + 'new', + ]); + await settleUi(t); + expect(unread('alice_fred'), findsNothing); + }, + ); + + testWidgets('long counts become a pill and say their number aloud', ( + t, + ) async { + final f = UiFixture(); + await f.seed(); + for (var i = 0; i < 120; i++) { + await f.chat.store.add( + message( + id: 'bulk-$i', + body: 'Message $i', + at: fixtureTime.add(Duration(seconds: i + 1)), + ), + ); + } + final semantics = t.ensureSemantics(); + await pumpList(t, f); + final badge = t.getRect(unread('alice_fred')); + expect(badge.height, 26); + expect(badge.width, greaterThan(26)); + expect(find.text('99+'), findsOneWidget); + expect( + find.bySemanticsLabel(RegExp('123 unread messages')), + findsOneWidget, + ); + semantics.dispose(); + }); + + testWidgets('muting from a long press greys the count and marks the row', ( + t, + ) async { + final f = UiFixture(); + await f.seed(); + await pumpList(t, f); + await t.longPress(row('alice_fred')); + await t.pumpAndSettle(); + await t.tap(find.text('Mute chat')); + await t.pumpAndSettle(); + await settleUi(t); + expect(find.byKey(const ValueKey('muted-alice_fred')), findsOneWidget); + final fill = + t + .widget( + find.descendant( + of: unread('alice_fred'), + matching: find.byType(Container), + ), + ) + .decoration! + as ShapeDecoration; + expect(fill.color, FireplaceUiTokens.dark.separator); + await t.longPress(row('alice_fred')); + await t.pumpAndSettle(); + expect(find.text('Unmute chat'), findsOneWidget); + }); + + testWidgets('a screen reader can mute without the long press', (t) async { + final f = UiFixture(); + await f.seed(); + final semantics = t.ensureSemantics(); + await pumpList(t, f); + final node = t.getSemantics(row('alice_fred')); + final data = node.getSemanticsData(); + final mute = data.customSemanticsActionIds! + .map(CustomSemanticsAction.getAction) + .singleWhere((action) => action!.label == 'Mute chat'); + t.binding.performSemanticsAction( + SemanticsActionEvent( + type: SemanticsAction.customAction, + nodeId: node.id, + viewId: t.view.viewId, + arguments: CustomSemanticsAction.getIdentifier(mute!), + ), + ); + await t.pumpAndSettle(); + await settleUi(t); + expect(find.byKey(const ValueKey('muted-alice_fred')), findsOneWidget); + semantics.dispose(); + }); + + testWidgets('enlarged text grows the row instead of overflowing', (t) async { + final f = UiFixture(); + await f.seed(); + await pumpList(t, f, textScale: 2); + expect(t.takeException(), isNull); + expect(t.getRect(row('alice_fred')).height, greaterThan(150)); + expect(t.getRect(unread('alice_fred')).height, 52); + }); + + testWidgets('a conversation without messages keeps the row height', ( + t, + ) async { + final f = UiFixture(); + await f.seed(); + f.chat.summaries = [ + ...f.chat.summaries, + ChatSummary('alice_nora', 'nora', fixtureTime), + ]; + await pumpList(t, f); + expect(t.getRect(row('alice_nora')).height, 104); + expect( + find.descendant( + of: row('alice_nora'), + matching: find.text('Encrypted chat'), + ), + findsOneWidget, + ); + }); + + group('chatListStamp', () { + const localizations = DefaultMaterialLocalizations(); + final now = DateTime(2026, 10, 4, 12); + String stamp(DateTime at) => chatListStamp(at, now, localizations); + test('shows a time today', () { + expect(stamp(DateTime(2026, 10, 4, 9, 5)), '9:05 AM'); + }); + test('shows a weekday within the last week', () { + expect(stamp(DateTime(2026, 10, 3, 23, 59)), 'Sat'); + expect(stamp(DateTime(2026, 9, 28, 0, 1)), 'Mon'); + }); + test('shows a date without the year after a week', () { + expect(stamp(DateTime(2026, 9, 27, 18)), '09/27'); + }); + test('keeps the year when it differs', () { + expect(stamp(DateTime(2025, 12, 31)), '12/31/2025'); + }); + test('treats a clock set back as today', () { + expect(stamp(DateTime(2026, 10, 5, 8)), '8:00 AM'); + }); + }); +}